refactor(core): adopt normalization-core leaf helpers across production (#120350)

* refactor(core): adopt normalization-core leaf helpers across production

* fix(ci): keep plugin contract source-resolvable

* fix(errors): preserve adapter-owned error fields

* fix(errors): short-circuit existing errors before stringifying

* fix(errors): skip throwing structured getters

* ci: retrigger checks on current base

* fix(errors): guard structured error enumeration

* fix: harden error detail copying
This commit is contained in:
Peter Steinberger
2026-08-08 12:00:49 -07:00
committed by GitHub
parent e81d7e62e8
commit e7a9f33d89
165 changed files with 748 additions and 848 deletions
@@ -1,16 +1,10 @@
// Memory Host SDK module implements embeddings remote fetch behavior.
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { postJson } from "./post-json.js";
import type { SsrFPolicy } from "./ssrf-policy.js";
// Fetches and validates OpenAI-compatible embedding responses.
/** Narrow unknown JSON payloads to plain objects. */
function asRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/** Build the common malformed embedding response error. */
function malformedEmbeddingResponse(errorPrefix: string): Error {
return new Error(`${errorPrefix}: malformed JSON response`);
@@ -31,7 +25,7 @@ function readEmbeddingVector(value: unknown, errorPrefix: string): number[] {
/** Resolve expected response count from the request body when input is an array. */
function resolveExpectedEmbeddingCount(body: unknown): number | undefined {
const input = asRecord(body)?.input;
const input = asOptionalRecord(body)?.input;
return Array.isArray(input) ? input.length : undefined;
}
@@ -54,7 +48,7 @@ export async function fetchRemoteEmbeddingVectors(params: {
body: params.body,
errorPrefix: params.errorPrefix,
parse: (payload) => {
const root = asRecord(payload);
const root = asOptionalRecord(payload);
if (!root || !Array.isArray(root.data)) {
throw malformedEmbeddingResponse(params.errorPrefix);
}
@@ -63,7 +57,7 @@ export async function fetchRemoteEmbeddingVectors(params: {
throw malformedEmbeddingResponse(params.errorPrefix);
}
return root.data.map((entry) => {
const record = asRecord(entry);
const record = asOptionalRecord(entry);
if (!record) {
throw malformedEmbeddingResponse(params.errorPrefix);
}
@@ -1,4 +1,5 @@
// Memory Host SDK module implements qmd query parser behavior.
import { asPositiveSafeInteger as parseQmdLineNumber } from "@openclaw/normalization-core/number-coercion";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { formatErrorMessage } from "./error-utils.js";
@@ -124,11 +125,6 @@ function parseQmdQueryResultArray(raw: string): QmdQueryResult[] | null {
}
}
/** Normalize qmd line numbers, rejecting zero, negative, and non-integer values. */
function parseQmdLineNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
/** Extract the first complete, standalone JSON result array from noisy stdout. */
function extractFirstJsonArray(raw: string): string | null {
let start = -1;
@@ -1,6 +1,9 @@
// Secret input parsing shared by memory provider config and gateway-resolved snapshots.
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { hasNonEmptyString } from "@openclaw/normalization-core/string-coerce";
import {
hasNonEmptyString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
/** Supported secret reference backing stores. */
type SecretRefSource = "env" | "file" | "exec";
@@ -18,15 +21,6 @@ const LEGACY_SECRETREF_ENV_MARKER_PREFIX = "secretref-env:";
const ENV_SECRET_TEMPLATE_RE = /^\$\{([A-Z][A-Z0-9_]{0,127})\}$/;
const SECRET_REF_SOURCES = new Set<SecretRefSource>(["env", "file", "exec"]);
/** Normalize literal secret strings and reject empty placeholders. */
function normalizeSecretInputString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/** Narrow a string to a supported SecretRef source. */
function hasSecretRefSource(value: unknown): value is SecretRefSource {
return typeof value === "string" && SECRET_REF_SOURCES.has(value as SecretRefSource);
@@ -111,7 +105,7 @@ function coerceSecretRef(value: unknown): SecretRef | null {
/** Return true when a secret input has either a literal value or resolvable reference shape. */
export function hasConfiguredMemorySecretInputValue(value: unknown): boolean {
if (normalizeSecretInputString(value)) {
if (normalizeOptionalString(value)) {
return true;
}
return coerceSecretRef(value) !== null;
@@ -139,7 +133,7 @@ export function normalizeResolvedMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
const normalized = normalizeSecretInputString(params.value);
const normalized = normalizeOptionalString(params.value);
if (normalized) {
return normalized;
}
@@ -152,5 +146,5 @@ export function normalizeResolvedMemorySecretInputString(params: {
/** Normalize env-provided secret values before use. */
export function normalizeEnvSecretInputString(value: unknown): string | undefined {
return normalizeSecretInputString(value);
return normalizeOptionalString(value);
}
@@ -1,4 +1,5 @@
// Model Catalog Core helper module supports model catalog normalize behavior.
import { asFiniteNumber as normalizeFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
@@ -127,10 +128,6 @@ function normalizeNonNegativeNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function normalizeFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizeStringOrNumber(value: unknown): string | number | undefined {
return normalizeOptionalString(value) ?? normalizeFiniteNumber(value);
}
+1
View File
@@ -44,6 +44,7 @@
"build": "tsdown src/index.ts src/ip.ts src/ipv4.ts src/redact-sensitive-url.ts src/url-protocol.ts src/url-userinfo.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"@openclaw/normalization-core": "workspace:*",
"ipaddr.js": "2.4.0"
}
}
+4 -12
View File
@@ -1,18 +1,10 @@
// Network Policy module implements ip behavior.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import ipaddr from "ipaddr.js";
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
/** Parsed IP address value returned by the net-policy parsing helpers. */
export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6;
type Ipv4Range = ReturnType<ipaddr.IPv4["range"]>;
@@ -1,12 +1,10 @@
// Network Policy module implements redact sensitive url behavior.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
type ConfigUiHintTags = {
tags?: string[];
};
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
/** Config UI hint tag for URL-like values that may embed credentials or tokens. */
export const SENSITIVE_URL_HINT_TAG = "url-secret";
@@ -4,6 +4,7 @@ import {
avoidTrailingHighSurrogateBreak,
sliceUtf16Safe,
truncateUtf16Safe,
truncateWithMarker,
} from "./utf16-slice.js";
describe("avoidTrailingHighSurrogateBreak", () => {
@@ -105,3 +106,52 @@ describe("truncateUtf16Safe", () => {
expect(truncateUtf16Safe(input, 1)).toBe("");
});
});
describe("truncateWithMarker", () => {
it.each([
{
name: "returns values at the boundary unchanged",
value: "hello",
max: 5,
options: { marker: "...", reserve: 3, trimEnd: false },
expected: "hello",
},
{
name: "reserves marker width",
value: "hello world",
max: 8,
options: { marker: "...", reserve: 3, trimEnd: false },
expected: "hello...",
},
{
name: "supports markers outside the limit",
value: "hello world",
max: 5,
options: { marker: "...", reserve: 0, trimEnd: false },
expected: "hello...",
},
{
name: "trims only the truncated prefix",
value: "hello world",
max: 9,
options: { marker: "...", reserve: 3, trimEnd: true },
expected: "hello...",
},
{
name: "keeps surrogate pairs well formed",
value: "ab🚀tail",
max: 4,
options: { marker: "…", reserve: 1, trimEnd: false },
expected: "ab…",
},
{
name: "preserves marker output at zero limits",
value: "hello",
max: 0,
options: { marker: "…", reserve: 1, trimEnd: false },
expected: "…",
},
] as const)("$name", ({ value, max, options, expected }) => {
expect(truncateWithMarker(value, max, options)).toBe(expected);
});
});
@@ -62,3 +62,16 @@ export function truncateUtf16Safe(input: string, maxLen: number): string {
}
return sliceUtf16Safe(input, 0, limit);
}
/** Truncates text and appends a marker while preserving the caller's reserved width contract. */
export function truncateWithMarker(
value: string,
max: number,
options: { marker: string; reserve: number; trimEnd: boolean },
): string {
if (value.length <= max) {
return value;
}
const prefix = truncateUtf16Safe(value, max - options.reserve);
return `${options.trimEnd ? prefix.trimEnd() : prefix}${options.marker}`;
}
+2 -14
View File
@@ -1,4 +1,6 @@
// External code plugin package.json compatibility and validation contracts.
import { isRecord } from "../../normalization-core/src/record-coerce.js";
import { normalizeOptionalString } from "../../normalization-core/src/string-coerce.js";
/** JSON object shape accepted by package contract helpers. */
export type JsonObject = Record<string, unknown>;
@@ -29,20 +31,6 @@ export const EXTERNAL_CODE_PLUGIN_REQUIRED_FIELD_PATHS = [
"openclaw.build.openclawVersion",
] as const;
/** Narrow unknown values to plain records. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Normalize optional package metadata strings. */
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
/** Read OpenClaw package.json blocks without trusting caller input shape. */
function readOpenClawBlock(packageJson: unknown) {
const root = isRecord(packageJson) ? packageJson : undefined;
+2 -1
View File
@@ -19,6 +19,7 @@
"build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"@openclaw/gateway-client": "workspace:*"
"@openclaw/gateway-client": "workspace:*",
"@openclaw/normalization-core": "workspace:*"
}
}
+1 -4
View File
@@ -1,5 +1,6 @@
// OpenClaw SDK module implements client behavior.
import { randomUUID } from "node:crypto";
import { asRecord } from "@openclaw/normalization-core/record-coerce";
import { EventHub } from "./event-hub.js";
import { normalizeGatewayEvent } from "./normalize.js";
import { GatewayClientTransport, isConnectableTransport } from "./transport.js";
@@ -222,10 +223,6 @@ type ChatProjection = {
payload: Record<string, unknown>;
};
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
}
function hasArtifactQueryScope(params: unknown): params is ArtifactQuery {
const record = asRecord(params);
return [record.sessionKey, record.runId, record.taskId].some(
+2 -9
View File
@@ -1,19 +1,12 @@
// OpenClaw SDK helper module supports normalize behavior.
import { asFiniteNumber as readNumber } from "@openclaw/normalization-core/number-coercion";
import { asRecord } from "@openclaw/normalization-core/record-coerce";
import type { GatewayEvent, JsonObject, OpenClawEvent, OpenClawEventType } from "./types.js";
// Normalize raw Gateway events into stable SDK event types and common metadata.
function asRecord(value: unknown): JsonObject {
return typeof value === "object" && value !== null ? (value as JsonObject) : {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function readNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readLowerString(value: unknown): string | undefined {
return readString(value)?.toLowerCase();
}
+1 -4
View File
@@ -1,3 +1,4 @@
import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce";
import type { PlainTextToolCallProtectedRangeResolver } from "./contracts.js";
// Tool Call Repair module implements promote behavior.
import { parseStandalonePlainTextToolCallBlocks, type PlainTextToolCallBlock } from "./payload.js";
@@ -45,10 +46,6 @@ export function createPromotedPlainTextToolCallBlock(
};
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
/** Emits the complete provider-neutral lifecycle for promoted tool-call blocks. */
export function createPromotedPlainTextToolCallEvents(
message: Record<string, unknown>,
+6
View File
@@ -2306,6 +2306,9 @@ importers:
packages/net-policy:
dependencies:
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../normalization-core
ipaddr.js:
specifier: 2.4.0
version: 2.4.0
@@ -2330,6 +2333,9 @@ importers:
'@openclaw/gateway-client':
specifier: workspace:*
version: link:../gateway-client
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../normalization-core
packages/session-url-contract: {}
+1 -4
View File
@@ -10,6 +10,7 @@ import {
type AnyMessage,
} from "@agentclientprotocol/sdk";
import type { AcpServerOptions } from "@openclaw/acp-core/types";
import { isRecord as isJsonObject } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
GATEWAY_CLIENT_CAPS,
@@ -289,10 +290,6 @@ function normalizeAcpInitializeProtocolVersion(message: AnyMessage): AnyMessage
} as AnyMessage;
}
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isUint16Integer(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 0xffff;
}
+1 -4
View File
@@ -35,6 +35,7 @@ import { defaultAcpSessionStore, type AcpSessionStore } from "@openclaw/acp-core
import { toAcpSessionLineageMeta } from "@openclaw/acp-core/session-lineage-meta";
import type { AcpServerOptions } from "@openclaw/acp-core/types";
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
import { normalizeLowercaseStringOrEmpty as normalizedChatSendAckStatus } from "@openclaw/normalization-core/string-coerce";
import {
normalizeFastMode,
normalizeOptionalString,
@@ -113,10 +114,6 @@ type ChatSendAck = {
status?: unknown;
};
function normalizedChatSendAckStatus(status: unknown): string {
return typeof status === "string" ? status.trim().toLowerCase() : "";
}
function isTerminalChatSendAckFailure(status: unknown): boolean {
const normalized = normalizedChatSendAckStatus(status);
return normalized === "timeout" || normalized === "error";
+7 -8
View File
@@ -1,7 +1,12 @@
/** Relays child ACP session stream updates back into the requester parent session. */
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import {
sliceUtf16Safe,
truncateUtf16Safe,
truncateWithMarker,
} from "@openclaw/normalization-core/utf16-slice";
import {
isAcpTagVisible,
resolveAcpProjectionSettings,
@@ -58,7 +63,7 @@ function truncate(value: string, maxChars: number): string {
if (maxChars <= 1) {
return truncateUtf16Safe(value, maxChars);
}
return `${truncateUtf16Safe(value, maxChars - 1)}`;
return truncateWithMarker(value, maxChars, { marker: "…", reserve: 1, trimEnd: false });
}
function normalizeStringArray(value: unknown): string[] {
@@ -75,12 +80,6 @@ function formatProxyEnvSummary(keys: string[]): string {
return `proxy env: ${keys.join(", ")}`;
}
function asObjectRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function mergeStreamingConfig(base: unknown, override: unknown): unknown {
const baseRecord = asObjectRecord(base);
const overrideRecord = asObjectRecord(override);
+1 -4
View File
@@ -1,3 +1,4 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import type { RestartRecoveryTerminalDeliveryEvidenceResult } from "../config/sessions/restart-recovery-types.js";
import type { SessionEntry } from "../config/sessions/types.js";
@@ -13,10 +14,6 @@ import {
} from "./embedded-agent-runner/delivery-evidence.js";
import { mergeAttemptToolMediaPayloads } from "./embedded-agent-runner/run/tool-media-payloads.js";
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeOptionalThreadId(value: unknown): string | undefined {
return (
normalizeOptionalString(value) ??
+1 -4
View File
@@ -1,4 +1,5 @@
/** Normalizes agent run wait/liveness/timeout metadata into sticky terminal outcomes. */
import { asFiniteNumber as asFiniteTimestamp } from "@openclaw/normalization-core/number-coercion";
import {
formatAbandonedLivenessError,
formatBlockedLivenessError,
@@ -461,10 +462,6 @@ export const AGENT_RUN_TERMINAL_RETRY_GRACE_MS = 15_000;
const HARD_TIMEOUT_PHASES = new Set<AgentRunTimeoutPhase>(["preflight", "provider", "post_turn"]);
function asFiniteTimestamp(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function asNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
@@ -1,4 +1,5 @@
/** Pure, non-resolving credential availability checks shared by status and route selection. */
import { hasNonEmptyString as hasSecret } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
isSecretRef,
@@ -20,10 +21,6 @@ import type { AuthProfileCredential } from "./types.js";
type ReadOnlyCredentialAvailability = boolean | undefined;
function hasSecret(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function hasMalformedSecretInputSyntax(value: unknown): boolean {
if (typeof value !== "string") {
return false;
+2 -5
View File
@@ -6,6 +6,7 @@
import fs from "node:fs";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { safeParseJson } from "@openclaw/normalization-core";
import { sha256HexPrefix } from "../../infra/crypto-digest.js";
import {
clearNodeSqliteKyselyCacheForDatabase,
@@ -85,11 +86,7 @@ function parseJsonCell(raw: string | null | undefined): unknown {
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
return safeParseJson(raw) ?? null;
}
type PersistedAuthProfileStoreInspection =
+1 -6
View File
@@ -12,6 +12,7 @@ import {
resolveExpiresAtMsFromEpochSeconds,
} from "@openclaw/normalization-core/number-coercion";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { cancelUnreadResponseBody } from "../../infra/http-body.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { readProviderJsonResponse } from "../provider-http-errors.js";
import { resolveProviderRequestHeaders } from "../provider-request-config.js";
@@ -253,12 +254,6 @@ function applyWhamCooldownResult(params: {
};
}
async function cancelUnreadResponseBody(response: Response): Promise<void> {
if (!response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
async function probeWhamForCooldown(
store: AuthProfileStore,
profileId: string,
+2 -2
View File
@@ -3,7 +3,7 @@
* These references are surfaced in agent context so follow-up turns can
* reconnect to prior long-running work.
*/
import { truncateUtf16Safe } from "../utils.js";
import { truncateUtf16Safe, truncateWithMarker } from "@openclaw/normalization-core/utf16-slice";
import { listRunningSessions } from "./bash-process-registry.js";
import { deriveSessionName } from "./bash-tools.shared.js";
@@ -31,7 +31,7 @@ function truncate(value: string, maxChars: number): string {
if (maxChars <= 1) {
return truncateUtf16Safe(value, maxChars);
}
return `${truncateUtf16Safe(value, Math.max(0, maxChars - 3))}...`;
return truncateWithMarker(value, maxChars, { marker: "...", reserve: 3, trimEnd: false });
}
/** List active background process sessions for one scope key, newest first. */
@@ -1,4 +1,5 @@
import { asNullableRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString as readString } from "@openclaw/normalization-core/string-coerce";
import type { OperatorScope } from "../gateway/operator-scopes.js";
import { renderExecUpdateText } from "./bash-tools.exec-output.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
@@ -27,10 +28,6 @@ type NodeSystemRunInvokeResult =
| { ok: true; raw: unknown }
| { ok: false; failure: NodeInvokeFailure };
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
/** Only NOT_CONNECTED plus explicit pre-dispatch provenance proves a retry cannot duplicate work. */
function classifyNodeInvokeFailure(error: unknown): NodeInvokeFailure {
const errorRecord = asNullableRecord(error);
+1 -6
View File
@@ -5,6 +5,7 @@
import { randomBytes } from "node:crypto";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sha256Base64Url } from "../infra/crypto-digest.js";
import { cancelUnreadResponseBody } from "../infra/http-body.js";
import { resolveExpiresAtMsFromDurationSeconds } from "../infra/parse-finite-number.js";
import type { OAuthCredentials } from "../llm/oauth.js";
import { buildOAuthRequestSignal } from "../llm/utils/oauth/abort.js";
@@ -102,12 +103,6 @@ function resolveChutesExpiresAt(value: unknown, now: number): number | undefined
});
}
async function cancelUnreadResponseBody(response: Response): Promise<void> {
if (!response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
async function fetchChutesUserInfo(params: {
accessToken: string;
fetchFn?: typeof fetch;
@@ -3,7 +3,7 @@
*/
import { safeParseJson } from "@openclaw/normalization-core";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import { hasNonEmptyString, readStringValue } from "@openclaw/normalization-core/string-coerce";
import type { SourceReplyDeliveryMode } from "../auto-reply/get-reply-options.types.js";
import {
isMessageToolConversationCreateActionName,
@@ -32,29 +32,22 @@ const BROADCAST_SEND_ENVELOPE_KEYS = ["payload", "result", "sendResult", "toolRe
const PARTIAL_DELIVERY_ENVELOPE_KEYS = [...RESULT_ENVELOPE_KEYS, "error", "cause"];
const SESSIONS_SEND_DELIVERY_STATUSES = new Set(["accepted", "ok"]);
const BARE_OK_DELIVERY_STATUS = "ok";
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function resultConfirmsCurrentSourceRoute(value: unknown): boolean {
return asRecord(asRecord(value).details).sourceReplyRoute === "current-source";
}
function hasStringValue(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
return (
(asOptionalRecord(asOptionalRecord(value)?.details) ?? {}).sourceReplyRoute === "current-source"
);
}
function hasConversationIdValue(value: unknown): boolean {
return hasStringValue(value) || (typeof value === "number" && Number.isFinite(value));
return hasNonEmptyString(value) || (typeof value === "number" && Number.isFinite(value));
}
function hasExplicitMessageRoute(args: Record<string, unknown>): boolean {
if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasStringValue(args[key]))) {
if (EXPLICIT_MESSAGE_ROUTE_KEYS.some((key) => hasNonEmptyString(args[key]))) {
return true;
}
return Array.isArray(args.targets) && args.targets.some((value) => hasStringValue(value));
return Array.isArray(args.targets) && args.targets.some((value) => hasNonEmptyString(value));
}
function isMessageToolSourceReplyActionName(action: unknown): boolean {
@@ -73,7 +66,7 @@ function isMessageToolSourceReplyActionName(action: unknown): boolean {
/** Read the visible text delivered by a source-reply message action. */
export function readMessageToolSourceReplyText(args: unknown): string | undefined {
const record = asRecord(args);
const record = asOptionalRecord(args) ?? {};
if (!isMessageToolSourceReplyActionName(record.action)) {
return undefined;
}
@@ -110,7 +103,7 @@ function recordHasDeliveredMessageId(record: Record<string, unknown>): boolean {
const normalized = normalizeStatus(value);
return Boolean(normalized && !NON_DELIVERY_MESSAGE_IDS.has(normalized));
};
const message = asRecord(record.message);
const message = asOptionalRecord(record.message) ?? {};
if (
hasDeliveredId(record.messageId) ||
hasDeliveredId(record.pollId) ||
@@ -504,7 +497,7 @@ export function isDeliveredMessagingToolResult(params: {
hookResult?: unknown;
isError?: boolean;
}): boolean {
const args = asRecord(params.args);
const args = asOptionalRecord(params.args) ?? {};
const action = normalizeStatus(args.action);
if (
args.dryRun === true ||
@@ -595,7 +588,7 @@ export function isDeliveredMessageToolOnlySourceReplyResult(params: {
if (normalizeToolName(params.toolName) !== MESSAGE_TOOL_NAME) {
return false;
}
const args = asRecord(params.args);
const args = asOptionalRecord(params.args) ?? {};
const sourceRouteReplyAction =
(params.allowExplicitSourceRoute === true || confirmedCurrentSourceRoute) &&
isMessageToolSourceReplyActionName(args.action);
@@ -1,3 +1,4 @@
import { hasNonEmptyString } from "@openclaw/normalization-core/string-coerce";
import { normalizeMediaReferenceForComparison } from "../../media/media-reference-comparison.js";
/**
* Extracts visible delivery evidence from embedded-agent run results.
@@ -97,10 +98,6 @@ export function hasCompletedTerminalDeliveryEvidence(
);
}
function hasNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function hasNonEmptyArray(value: unknown): boolean {
return Array.isArray(value) && value.length > 0;
}
@@ -2,6 +2,7 @@
* Builds extension factories available to embedded-agent runtime sessions.
*/
import { randomUUID } from "node:crypto";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
import { normalizeAcceptedSessionSpawnResult } from "../accepted-session-spawn.js";
@@ -31,14 +32,8 @@ type AgentToolResultEvent = {
isError?: boolean;
};
function recordFromUnknown(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function snapshotToolSendReceipt(details: unknown): unknown {
const toolSend = recordFromUnknown(details).toolSend;
const toolSend = (asOptionalRecord(details) ?? {}).toolSend;
return toolSend && typeof toolSend === "object" && !Array.isArray(toolSend)
? { ...(toolSend as Record<string, unknown>) }
: toolSend;
@@ -66,7 +61,7 @@ function buildAgentToolResultMiddlewareFactory(
});
return (agent) => {
agent.on("tool_result", async (rawEvent: unknown, ctx: { cwd?: string }) => {
const event = recordFromUnknown(rawEvent) as AgentToolResultEvent;
const event = (asOptionalRecord(rawEvent) ?? {}) as AgentToolResultEvent;
if (!event.toolName) {
return undefined;
}
@@ -94,7 +89,7 @@ function buildAgentToolResultMiddlewareFactory(
turnId: event.turnId,
toolCallId,
toolName: event.toolName,
args: recordFromUnknown(adjustedInput ?? event.input),
args: asOptionalRecord(adjustedInput ?? event.input) ?? {},
cwd: ctx.cwd,
isError: event.isError,
result: current,
@@ -16,7 +16,7 @@ import {
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { parseGeminiAuth } from "../../infra/gemini-auth.js";
import { normalizeGoogleApiBaseUrl } from "../../infra/google-api-base-url.js";
import { readResponseWithLimit } from "../../infra/http-body.js";
import { cancelUnreadResponseBody, readResponseWithLimit } from "../../infra/http-body.js";
import { streamWithPayloadPatch } from "../../llm/providers/stream-wrappers/stream-payload-utils.js";
import type { Model } from "../../llm/types.js";
import { isSecretValueRegisteredForRedaction } from "../../logging/secret-redaction-registry.js";
@@ -286,12 +286,6 @@ function buildManagedContextForCachedContent(context: GooglePromptCacheContext)
};
}
async function cancelUnreadResponseBody(response: Response | undefined): Promise<void> {
if (response && !response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
/**
* Reads a Google cachedContents JSON body under a byte cap and parses it.
* Streams through the shared limiter so an oversized response is cancelled
@@ -1,3 +1,4 @@
import { hasNonEmptyString } from "@openclaw/normalization-core/string-coerce";
import {
isSilentReplyPayloadText,
isSilentReplyText,
@@ -24,10 +25,6 @@ type PayloadVisibilityOptions = {
includeSilentReplyPayloads?: boolean;
};
function hasNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function hasNonEmptyStringArray(value: unknown): boolean {
return Array.isArray(value) && value.some(hasNonEmptyString);
}
@@ -1,3 +1,4 @@
import { asOptionalRecord as readModelParams } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { ModelCompatConfig, ModelMediaInputConfig } from "../../config/types.models.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -228,13 +229,6 @@ export function hasConfiguredFallbackSurface(params: {
return Boolean(params.providerConfig?.baseUrl?.trim());
}
function readModelParams(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
function mergeModelParams(
...entries: Array<Record<string, unknown> | undefined>
): Record<string, unknown> | undefined {
@@ -19,7 +19,7 @@
*/
import { formatErrorMessage } from "../../infra/errors.js";
import { readResponseWithLimit } from "../../infra/http-body.js";
import { cancelUnreadResponseBody, readResponseWithLimit } from "../../infra/http-body.js";
import { resolveProxyFetchFromEnv } from "../../infra/net/proxy-fetch.js";
import { parseStrictFiniteNumber } from "../../infra/parse-finite-number.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
@@ -177,12 +177,6 @@ function parseModel(model: OpenRouterApiModel): OpenRouterModelCapabilities {
};
}
async function cancelUnreadResponseBody(response: Response | undefined): Promise<void> {
if (response && !response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
// ---------------------------------------------------------------------------
// API fetch
// ---------------------------------------------------------------------------
@@ -2,6 +2,7 @@
* Sanitizes and validates replayed session history before model calls.
*/
import { isDeepStrictEqual } from "node:util";
import { asFiniteNumber as toFiniteCostNumber } from "@openclaw/normalization-core/number-coercion";
import { stripInternalMetadataForDisplay } from "../../auto-reply/reply/display-text-sanitize.js";
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -522,10 +523,6 @@ function normalizeAssistantUsageCost(usage: unknown): AssistantUsageSnapshot["co
return { input, output, cacheRead, cacheWrite, total, ...(totalOrigin ? { totalOrigin } : {}) };
}
function toFiniteCostNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function ensureAssistantUsageSnapshots(messages: AgentMessage[]): AgentMessage[] {
if (messages.length === 0) {
return messages;
@@ -2,7 +2,10 @@
* Normalizes tool-call names, ids, and standalone text calls for providers.
*/
import { randomUUID } from "node:crypto";
import { normalizeLowercaseStringOrEmpty } from "../../../../packages/normalization-core/src/string-coerce.js";
import {
hasNonEmptyString as replayToolCallNonEmptyString,
normalizeLowercaseStringOrEmpty,
} from "../../../../packages/normalization-core/src/string-coerce.js";
import { normalizeStringEntries } from "../../../../packages/normalization-core/src/string-normalization.js";
import {
createPromotedPlainTextToolCallEvents,
@@ -336,10 +339,6 @@ function collectFollowingToolResults(
return { ids, displaced };
}
function replayToolCallNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function resolveReplayToolCallName(
rawName: string,
rawId: string,
@@ -4,6 +4,7 @@
import type { LlmRuntime } from "@openclaw/ai";
import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared";
import { createBoundaryAwareStreamFnForModel } from "@openclaw/ai/transports";
import { hasNonEmptyString as hasResolvedRuntimeApiKey } from "@openclaw/normalization-core/string-coerce";
import { getStreamLlmRuntime } from "../../llm/model-runtime-binding.js";
import "../ai-transport-runtime-host.js";
import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js";
@@ -69,10 +70,6 @@ function isDefaultOpenClawStreamFnForModel(
return streamFn === provider?.streamSimple || streamFn === provider?.stream;
}
function hasResolvedRuntimeApiKey(apiKey: string | undefined): boolean {
return typeof apiKey === "string" && apiKey.trim().length > 0;
}
function isOpenAICodexResponsesModel(model: EmbeddedRunAttemptParams["model"]): boolean {
return model.provider === "openai" && model.api === "openai-chatgpt-responses";
}
@@ -1,4 +1,4 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { truncateUtf16Safe, truncateWithMarker } from "@openclaw/normalization-core/utf16-slice";
import type {
JsonValue,
NativeHookRelayEvent,
@@ -213,8 +213,5 @@ function snapshotString(value: string, state: { remainingStringLength: number })
}
export function truncateText(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${truncateUtf16Safe(value, Math.max(0, maxLength - 3))}...`;
return truncateWithMarker(value, maxLength, { marker: "...", reserve: 3, trimEnd: false });
}
+1 -4
View File
@@ -1,4 +1,5 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeOptionalString as readStringParam } from "@openclaw/normalization-core/string-coerce";
import {
resolveMergedModelProviderConfig,
resolveMergedModelProviderModels,
@@ -258,10 +259,6 @@ function isSupportedHarness(entry: {
return entry.support.supported;
}
function readStringParam(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeModelId(provider: string, modelId: string): string {
const trimmed = modelId.trim();
const slashIndex = trimmed.indexOf("/");
+1 -4
View File
@@ -4,6 +4,7 @@ import {
normalizeProviderId,
normalizeProviderIdForAuth,
} from "@openclaw/model-catalog-core/provider-id";
import { hasNonEmptyString as hasSecret } from "@openclaw/normalization-core/string-coerce";
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import { resolveMergedModelProviderConfig } from "../config/model-provider-config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -128,10 +129,6 @@ type AuthSourceEvaluation = Pick<
"availability" | "selectedAuthMode" | "evidence" | "selectedProfileId"
>;
function hasSecret(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function modeAllowed(provider: string, target: AuthTarget, mode: string | undefined): boolean {
const requirement = resolveProviderModelRouteAuthRequirement(mode);
return target.authRequirement
+1 -4
View File
@@ -1,3 +1,4 @@
import { normalizeLowercaseStringOrEmpty as normalizeApi } from "@openclaw/normalization-core/string-coerce";
import type { ModelCompatConfig } from "../config/types.models.js";
type ModelTransportRoute = {
@@ -5,10 +6,6 @@ type ModelTransportRoute = {
baseUrl?: unknown;
};
function normalizeApi(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normalizeBaseUrl(value: unknown): string {
if (typeof value !== "string") {
return "";
+2 -3
View File
@@ -20,6 +20,7 @@ import {
import pMap from "p-map";
import { Type } from "typebox";
import { formatErrorMessage } from "../infra/errors.js";
import { cancelUnreadResponseBody } from "../infra/http-body.js";
/**
* Scans remote provider model catalogs for configured providers.
*/
@@ -284,9 +285,7 @@ async function fetchOpenRouterModels(
"OpenRouter model scan",
);
} finally {
if (res && !res.bodyUsed) {
await res.body?.cancel().catch(() => undefined);
}
await cancelUnreadResponseBody(res);
}
}
+3 -9
View File
@@ -3,6 +3,8 @@
* store preserves typed columns for hot delivery state while retaining the
* normalized payload JSON for forward-compatible record hydration.
*/
import { safeParseJson } from "@openclaw/normalization-core";
import { asFiniteNumber as normalizeFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { sql, type Insertable, type Selectable, type Updateable } from "kysely";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
@@ -82,21 +84,13 @@ function parseJson(raw: string | null): unknown {
if (!raw) {
return undefined;
}
try {
return JSON.parse(raw);
} catch {
return undefined;
}
return safeParseJson(raw);
}
function boolToSqlite(value: boolean | undefined): number | null {
return value === undefined ? null : value ? 1 : 0;
}
function normalizeFiniteNumber(value: number | null): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/** Rehydrates one sqlite row into the normalized subagent run record shape. */
function rowToSubagentRunRecord(row: SubagentRunSqliteRow): SubagentRunRecord | null {
const payload = parseJson(row.payload_json);
+1 -4
View File
@@ -1,6 +1,7 @@
// Persists runtime tool-schema quarantines in the shared SQLite-backed core
// plugin-state store so health surfaces can see failures from any live
// runtime process.
import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce";
import {
createRuntimeHealthRecordEnvelope,
createRuntimeHealthStore,
@@ -20,10 +21,6 @@ type PersistedRuntimeToolSchemaQuarantineRecord = RuntimeHealthRecordEnvelope &
reason: string;
};
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
const quarantineStore = createRuntimeHealthStore<PersistedRuntimeToolSchemaQuarantineRecord>({
ownerId: "core:runtime-tool-quarantine-health",
namespace: "schema-quarantines",
+1 -4
View File
@@ -4,6 +4,7 @@
* Recovers flat or partial model/tool inputs into the structured cron job/patch shape.
*/
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
import { hasNonEmptyString as isNonEmptyString } from "@openclaw/normalization-core/string-coerce";
import { isRecord } from "../../utils.js";
import { isStringOption } from "../../utils/string-readers.js";
@@ -75,10 +76,6 @@ function isCronPayloadKind(value: unknown): value is (typeof CRON_PAYLOAD_KINDS)
return value === "systemEvent" || value === "agentTurn" || value === "script";
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isStringArrayOrNull(value: unknown): boolean {
return (
value === null || (Array.isArray(value) && value.every((entry) => typeof entry === "string"))
+2 -6
View File
@@ -1,7 +1,7 @@
import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice";
/** Reminder-context projection for cron tool job creation. */
import { getRuntimeConfig } from "../../config/config.js";
import { extractTextFromChatContent } from "../../shared/chat-content.js";
import { truncateUtf16Safe } from "../../utils.js";
import { REMINDER_CONTEXT_MESSAGES_MAX } from "./cron-tool-schema.js";
import type { ChatMessage, GatewayToolCaller } from "./cron-tool.types.js";
import type { GatewayCallOptions } from "./gateway.js";
@@ -20,11 +20,7 @@ export function stripExistingContext(text: string) {
}
function truncateText(input: string, maxLen: number) {
if (input.length <= maxLen) {
return input;
}
const truncated = truncateUtf16Safe(input, Math.max(0, maxLen - 3)).trimEnd();
return `${truncated}...`;
return truncateWithMarker(input, maxLen, { marker: "...", reserve: 3, trimEnd: true });
}
function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
+2 -7
View File
@@ -4,6 +4,7 @@
* Manages live capture, manual import, summarization, and process-local transcript sessions.
*/
import path from "node:path";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { Type } from "typebox";
import { resolveStateDir } from "../../config/paths.js";
@@ -59,12 +60,6 @@ function ownsTranscriptSession(
return ctx.agentId === "main";
}
function asParamsRecord(params: unknown): Record<string, unknown> {
return params && typeof params === "object" && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
}
const TranscriptsSchema = Type.Object(
{
action: Type.String({
@@ -356,7 +351,7 @@ export function createTranscriptsTool(options?: {
if (!config.enabled) {
throw new Error("transcripts are disabled");
}
const params = asParamsRecord(rawParams);
const params = asOptionalRecord(rawParams) ?? {};
const action = readStringParam(params, "action", { required: true, trim: true });
const store = createStore(ctx);
switch (action) {
+1 -4
View File
@@ -7,6 +7,7 @@
* re-wrapped here unconditionally, so no provider-controlled metadata can
* spoof the trust marker and transport-specific extras never reach the model.
*/
import { asFiniteNumber as readFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { Static } from "typebox";
@@ -119,10 +120,6 @@ function unwrapEnvelopes(value: string): string {
return value.replace(ENVELOPE_OPEN_RE, "").replace(ENVELOPE_END_RE, "").trim();
}
function readFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
// URLs are emitted canonicalized (percent-encoded), so whitespace or readable
// prose smuggled into a URL slot cannot ride outside the envelope as-is.
function toHttpUrl(value: string): string | undefined {
+1 -6
View File
@@ -22,6 +22,7 @@ import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import chalk from "chalk";
import { extractArchive } from "../../infra/archive.js";
import { isTruthyEnvValue } from "../../infra/env.js";
import { cancelUnreadResponseBody } from "../../infra/http-body.js";
import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js";
import { APP_NAME, getBinDir } from "../config.js";
import { readProviderJsonResponse } from "../provider-http-errors.js";
@@ -36,12 +37,6 @@ const ARCHIVE_EXTRACT_TIMEOUT_MS = 60_000;
const CONTENT_LENGTH_RE = /^\d+$/;
const GITHUB_RELEASE_JSON_MAX_BYTES = 1024 * 1024;
async function cancelUnreadResponseBody(response: Response): Promise<void> {
if (!response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
function isOfflineModeEnabled(): boolean {
return isTruthyEnvValue(process.env.OPENCLAW_OFFLINE);
}
+1 -6
View File
@@ -2,7 +2,6 @@
// unintentionally breaking on newlines. Using [\s\S] keeps newlines inside
// the chunk so messages are only split when they truly exceed the limit.
import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion";
import {
findFenceSpanAt,
isSafeFenceBreak,
@@ -16,6 +15,7 @@ import { normalizeAccountId } from "../routing/session-key.js";
import {
avoidTrailingHighSurrogateBreak,
chunkTextByBreakResolver,
normalizeChunkLimit,
} from "../shared/text-chunking.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js";
@@ -33,11 +33,6 @@ export type ChunkMode = "length" | "newline";
const DEFAULT_CHUNK_LIMIT = 4000;
const DEFAULT_CHUNK_MODE: ChunkMode = "length";
function normalizeChunkLimit(limit: number): number {
// String slicing truncates fractional indexes, so positive limits need an integer progress step.
return Number.isFinite(limit) && limit > 0 ? resolveIntegerOption(limit, 1, { min: 1 }) : limit;
}
type ProviderChunkConfig = {
textChunkLimit?: number;
streaming?: unknown;
+2 -2
View File
@@ -4,7 +4,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { truncateUtf16Safe, truncateWithMarker } from "@openclaw/normalization-core/utf16-slice";
import { resolveAcpToolTerminalOutcome } from "../../acp/tool-status.js";
import { EmbeddedBlockChunker } from "../../agents/embedded-agent-block-chunker.js";
import { formatToolSummary, resolveToolDisplay } from "../../agents/tool-display.js";
@@ -53,7 +53,7 @@ function truncateText(input: string, maxChars: number): string {
if (maxChars <= 1) {
return truncateUtf16Safe(input, maxChars);
}
return `${truncateUtf16Safe(input, maxChars - 1)}`;
return truncateWithMarker(input, maxChars, { marker: "…", reserve: 1, trimEnd: false });
}
function hashText(text: string): string {
@@ -1,3 +1,5 @@
import { asFiniteNumber as readFiniteNumberValue } from "@openclaw/normalization-core/number-coercion";
import { asOptionalRecord as readRecordValue } from "@openclaw/normalization-core/record-coerce";
import {
normalizeLowercaseStringOrEmpty,
readStringValue,
@@ -5,12 +7,6 @@ import {
import { inferToolMetaFromArgs } from "../../agents/embedded-agent-utils.js";
import type { GetReplyOptions } from "../types.js";
function readRecordValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
/**
* CLI backends report a tool result as its raw content: a string, or the text
* blocks the harness streamed. Structured runners send a record instead, so the
@@ -32,10 +28,6 @@ function readToolResultText(value: unknown): string | undefined {
return text || undefined;
}
function readFiniteNumberValue(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function readNullableNumberValue(value: unknown): number | null | undefined {
if (value === null) {
return null;
+5 -8
View File
@@ -2,6 +2,7 @@
import { randomUUID } from "node:crypto";
import { toAcpRuntimeErrorText } from "@openclaw/acp-core/runtime/error-text";
import type { AcpRuntimeSessionMode } from "@openclaw/acp-core/runtime/types";
import type { Result } from "@openclaw/normalization-core/result";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -183,7 +184,7 @@ function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnTh
export function parseSpawnInput(
params: HandleCommandsParams,
tokens: string[],
): { ok: true; value: ParsedSpawnInput } | { ok: false; error: string } {
): Result<ParsedSpawnInput, string> {
const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token));
let mode: AcpRuntimeSessionMode = "persistent";
let thread = resolveDefaultSpawnThreadMode(params);
@@ -323,9 +324,7 @@ export function parseSpawnInput(
};
}
export function parseSteerInput(
tokens: string[],
): { ok: true; value: ParsedSteerInput } | { ok: false; error: string } {
export function parseSteerInput(tokens: string[]): Result<ParsedSteerInput, string> {
const normalizedTokens = tokens.map((token) => normalizeAcpOptionToken(token));
let sessionToken: string | undefined;
const instructionTokens: string[] = [];
@@ -372,7 +371,7 @@ export function parseSteerInput(
export function parseSingleValueCommandInput(
tokens: string[],
usage: string,
): { ok: true; value: ParsedSingleValueCommandInput } | { ok: false; error: string } {
): Result<ParsedSingleValueCommandInput, string> {
const value = normalizeOptionalString(tokens[0]) ?? "";
if (!value) {
return { ok: false, error: usage };
@@ -390,9 +389,7 @@ export function parseSingleValueCommandInput(
};
}
export function parseSetCommandInput(
tokens: string[],
): { ok: true; value: ParsedSetCommandInput } | { ok: false; error: string } {
export function parseSetCommandInput(tokens: string[]): Result<ParsedSetCommandInput, string> {
const key = normalizeOptionalString(tokens[0]) ?? "";
const value = normalizeOptionalString(tokens[1]) ?? "";
if (!key || !value) {
+1 -4
View File
@@ -1,6 +1,7 @@
import { type FSWatcher, readFileSync, watch } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import { isRecord as isPlainObject } from "@openclaw/normalization-core/record-coerce";
import { createDedupeCache } from "../../infra/dedupe.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js";
@@ -31,10 +32,6 @@ function expandPath(p: string): string {
return isAbsolute(p) ? p : resolve(p);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasPieces(value: unknown): boolean {
return Array.isArray(value) && value.some(isPlainObject);
}
+1 -3
View File
@@ -1,15 +1,13 @@
import {
asSafeIntegerInRange,
expectDefined,
isRecord as isObject,
parseStrictInteger,
} from "@openclaw/normalization-core";
export type UsageBarTemplate = Record<string, unknown>;
export type UsageContract = Record<string, unknown>;
type Vocab = Record<string, unknown>;
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
function toGlyphs(scale: unknown): string[] {
if (Array.isArray(scale)) {
return scale.filter((g): g is string => typeof g === "string");
+1 -6
View File
@@ -3,6 +3,7 @@
*
* Reads, writes, migrates, and normalizes direct-message policy and allowFrom fields.
*/
import { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
/**
@@ -52,12 +53,6 @@ export function normalizeChannelDmPolicy(value: string | undefined): ChannelDmPo
: undefined;
}
function asObjectRecord(value: unknown): DmAccessRecord | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as DmAccessRecord)
: null;
}
function cloneDm(entry: DmAccessRecord): DmAccessRecord | null {
const dm = asObjectRecord(entry.dm);
return dm ? { ...dm } : null;
+1 -6
View File
@@ -1,5 +1,6 @@
import { expectDefined } from "@openclaw/normalization-core";
// Channel streaming config normalization and progress-draft formatting helpers.
import { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import {
@@ -40,12 +41,6 @@ export type { SlackChannelStreamingConfig } from "../config/types.slack.js";
// Runtime reads are nested-only; doctor migrates legacy streaming spellings.
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
}
+1 -4
View File
@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { isRecord as isObjectRecord } from "@openclaw/normalization-core/record-coerce";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -401,10 +402,6 @@ function buildTtsConfigWithHydratedProvider(params: {
return tts;
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function ttsProviderConfigHasApiKey(value: unknown): boolean {
return isObjectRecord(value) && "apiKey" in value;
}
+6 -11
View File
@@ -4,6 +4,7 @@ import * as path from "node:path";
import { canonicalizeBase64, estimateBase64DecodedBytes } from "@openclaw/media-core/base64";
import { parseMediaContentLength } from "@openclaw/media-core/content-length";
import { toErrorObject } from "../infra/errors.js";
import { cancelUnreadResponseBody } from "../infra/http-body.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { normalizeHostname } from "../infra/net/hostname.js";
import { resolveCliName } from "./cli-name.js";
@@ -81,12 +82,6 @@ type CameraClipPayload = {
hasAudio: boolean;
};
async function cancelIgnoredResponseBody(response: Response | undefined): Promise<void> {
if (response?.bodyUsed !== true) {
await response?.body?.cancel().catch(() => undefined);
}
}
/** Validate and normalize an unknown camera still-image payload. */
export function parseCameraSnapPayload(value: unknown): CameraSnapPayload {
const obj = asRecord(value);
@@ -170,13 +165,13 @@ async function writeUrlToFile(filePath: string, url: string, opts: { expectedHos
const res = guarded.response;
const finalUrl = new URL(guarded.finalUrl);
if (normalizeHostname(finalUrl.hostname) !== expectedHost) {
await cancelIgnoredResponseBody(res);
await cancelUnreadResponseBody(res);
throw new Error(
`writeUrlToFile: redirect host ${finalUrl.hostname} must match node host ${opts.expectedHost}`,
);
}
if (!res.ok) {
await cancelIgnoredResponseBody(res);
await cancelUnreadResponseBody(res);
throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`);
}
@@ -184,11 +179,11 @@ async function writeUrlToFile(filePath: string, url: string, opts: { expectedHos
try {
contentLength = parseMediaContentLength(res.headers.get("content-length"));
} catch (err) {
await cancelIgnoredResponseBody(res);
await cancelUnreadResponseBody(res);
throw err;
}
if (contentLength !== null && contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES) {
await cancelIgnoredResponseBody(res);
await cancelUnreadResponseBody(res);
throw new Error(
`writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
);
@@ -196,7 +191,7 @@ async function writeUrlToFile(filePath: string, url: string, opts: { expectedHos
const body = res.body;
if (!body) {
await cancelIgnoredResponseBody(res);
await cancelUnreadResponseBody(res);
throw new Error(`failed to download ${url}: empty response body`);
}
+2 -2
View File
@@ -2,7 +2,7 @@
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeUniqueStringEntries } from "@openclaw/normalization-core/string-normalization";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -24,7 +24,7 @@ const STATUS_VALUES = new Set<CommitmentStatus>([
]);
function truncate(value: string, maxChars: number): string {
return value.length <= maxChars ? value : `${truncateUtf16Safe(value, maxChars - 1)}`;
return truncateWithMarker(value, maxChars, { marker: "…", reserve: 1, trimEnd: false });
}
function safe(value: string): string {
@@ -1,3 +1,4 @@
import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
createAccountCronScheduledToolPolicy,
@@ -36,12 +37,6 @@ export function createScheduledToolPolicyMigrationCollector() {
};
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function usesToolRuntime(raw: Record<string, unknown>): boolean {
const payload = readRecord(raw.payload);
const trigger = readRecord(raw.trigger);
@@ -1,5 +1,6 @@
import { isDeepStrictEqual } from "node:util";
import { normalizeConfiguredProviderCatalogModelId } from "@openclaw/model-catalog-core/provider-model-id-normalization";
import { normalizeLowercaseStringOrEmpty as normalizeString } from "@openclaw/normalization-core/string-coerce";
import { splitTrailingAuthProfile } from "../../../agents/model-ref-profile.js";
import { ensureRecord, getRecord } from "../../../config/legacy.shared.js";
import { normalizeAgentModelRefForConfig } from "../../../config/model-input.js";
@@ -14,10 +15,6 @@ export function hasOwnDefinedProperty(record: Record<string, unknown>, key: stri
return Object.hasOwn(record, key) && record[key] !== undefined;
}
function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function preferredClaudeSeparator(provider: string | undefined): "." | "-" {
return provider === "github-copilot" || provider === "copilot-proxy" ? "." : "-";
}
+1 -6
View File
@@ -1,7 +1,2 @@
// Shared nullable record guard for doctor config walkers.
export function asObjectRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
export { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce";
+2 -2
View File
@@ -1,7 +1,7 @@
/** CLI commands for listing, inspecting, and cancelling TaskFlow records. */
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { truncateUtf16Safe, truncateWithMarker } from "@openclaw/normalization-core/utf16-slice";
import { truncateToVisibleWidth, visibleWidth } from "../../packages/terminal-core/src/ansi.js";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
@@ -38,7 +38,7 @@ function truncate(value: string, maxChars: number) {
if (maxChars <= 1) {
return truncateUtf16Safe(value, maxChars);
}
return `${truncateUtf16Safe(value, maxChars - 1)}`;
return truncateWithMarker(value, maxChars, { marker: "…", reserve: 1, trimEnd: false });
}
function safeFlowDisplayText(value: string | undefined, maxChars?: number): string {
@@ -1,6 +1,7 @@
/** Reads persisted generated catalogs without constructing a model registry. */
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString as readString } from "@openclaw/normalization-core/string-coerce";
import type { ModelCatalogEntry, ModelInputType } from "../../agents/model-catalog.types.js";
import {
filterGeneratedPluginModelCatalogProviders,
@@ -13,10 +14,6 @@ import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snaps
const modelApis = new Set<string>(MODEL_APIS);
const modelInputs = new Set<ModelInputType>(["text", "image", "audio", "video", "document"]);
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readPositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
+1 -4
View File
@@ -4,6 +4,7 @@
* It selects active or requested sessions, renders recent trajectory events,
* and can follow newly appended SQLite trajectory rows.
*/
import { normalizeOptionalString as toOptionalString } from "@openclaw/normalization-core/string-coerce";
import { readAcpSessionMeta } from "../acp/runtime/session-meta.js";
import { getRuntimeConfig } from "../config/config.js";
import { listSessionEntriesReadOnly } from "../config/sessions/session-accessor.js";
@@ -79,10 +80,6 @@ function parseTailCount(value: string | number | undefined): number | null {
return parseStrictNonNegativeInteger(value) ?? null;
}
function toOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function formatTimestamp(ts: string): string {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) {
+4 -9
View File
@@ -1,6 +1,7 @@
// Gateway log-tail helpers for status diagnostics.
// Summaries compact repeated auth/runtime failures while preserving enough context for operators.
import { safeParseJson } from "@openclaw/normalization-core";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { classifyOAuthRefreshFailureReason } from "../../agents/auth-profiles/oauth-refresh-failure.js";
@@ -108,15 +109,9 @@ export function summarizeLogTail(rawLines: string[], opts?: { maxLines?: number
const block = consumeJsonBlock(lines, i);
if (block) {
i = block.endIndex;
const parsed = (() => {
try {
return JSON.parse(block.json) as {
error?: { code?: string; message?: string };
};
} catch {
return null;
}
})();
const parsed = (safeParseJson(block.json) ?? null) as {
error?: { code?: string; message?: string };
} | null;
const code = normalizeOptionalString(parsed?.error?.code) ?? null;
const msg = normalizeOptionalString(parsed?.error?.message) ?? null;
const refreshReason = classifyOAuthRefreshFailureReason(msg ?? "");
+4 -2
View File
@@ -3,7 +3,7 @@
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
import { formatCliCommand } from "../cli/command-format.js";
@@ -206,7 +206,9 @@ function truncate(value: string, maxChars: number) {
if (value.length <= maxChars) {
return value;
}
return maxChars <= 0 ? "" : `${truncateUtf16Safe(value, maxChars - 1)}`;
return maxChars <= 0
? ""
: truncateWithMarker(value, maxChars, { marker: "…", reserve: 1, trimEnd: false });
}
function shortToken(value: string | undefined, maxChars = ID_PAD): string {
+2 -7
View File
@@ -1,10 +1,12 @@
// Normalizes channel config compatibility fields during config loading.
import { asNullableRecord as asObjectRecord } from "@openclaw/normalization-core/record-coerce";
import {
normalizeLegacyDmAliases,
type CompatMutationResult,
} from "../channels/plugins/dm-access.js";
export { normalizeLegacyDmAliases };
export { asObjectRecord };
export type { CompatMutationResult };
/** Resolved streaming values a channel doctor supplies while migrating legacy aliases. */
@@ -41,13 +43,6 @@ export type RetiredChannelKeyRemoval = {
pathPrefix: string;
};
/** Narrows unknown config JSON values to mutable object records. */
export function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function parseAliasStreamingMode(value: unknown): "off" | "partial" | "block" | "progress" | null {
if (typeof value !== "string") {
return null;
+1 -4
View File
@@ -1,4 +1,5 @@
// Normalizes MCP config records into canonical runtime shape.
import { normalizeLowercaseStringOrEmpty as normalizeMcpString } from "@openclaw/normalization-core/string-coerce";
import { isRecord } from "../utils.js";
type ConfigMcpServers = Record<string, Record<string, unknown>>;
@@ -11,10 +12,6 @@ const CLI_MCP_TYPE_TO_OPENCLAW_TRANSPORT: Record<string, OpenClawMcpHttpTranspor
stdio: "stdio",
};
function normalizeMcpString(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
/** Maps CLI-native MCP type aliases to OpenClaw HTTP transport names. */
export function resolveOpenClawMcpTransportAlias(
value: unknown,
+1 -6
View File
@@ -1,4 +1,5 @@
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { asOptionalRecord as readRecord } from "@openclaw/normalization-core/record-coerce";
import type { ProviderRouteOverridePresence } from "../plugin-sdk/provider-model-types.js";
import type { ModelDefinitionConfig, ModelProviderConfig } from "./types.models.js";
import type { OpenClawConfig } from "./types.openclaw.js";
@@ -36,12 +37,6 @@ function normalizeModelId(provider: string, modelId: string): string {
: trimmed;
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function hasNonEmptyRecord(value: unknown): boolean {
const record = readRecord(value);
return record !== undefined && Object.keys(record).length > 0;
+1 -4
View File
@@ -1,3 +1,4 @@
import { normalizeOptionalString as normalizeText } from "@openclaw/normalization-core/string-coerce";
import type { MsgContext } from "../../auto-reply/templating.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { resolveConversationLabel } from "../../channels/conversation-label.js";
@@ -35,10 +36,6 @@ export type ConversationIdentity = {
metadata?: Record<string, unknown>;
};
function normalizeText(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeThreadId(value: unknown): string | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
@@ -1,4 +1,5 @@
import { isDeepStrictEqual } from "node:util";
import { normalizeOptionalString as normalizeRunId } from "@openclaw/normalization-core/string-coerce";
import {
normalizeDeliveryContext,
type DeliveryContext,
@@ -17,10 +18,6 @@ type RestartRecoveryChannelAuthority = {
sourceTurnId: string;
};
function normalizeRunId(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
/** Resolves only a complete durable channel claim; session-route fallbacks carry no authority. */
export function resolveRestartRecoveryChannelAuthority(
entry: SessionEntry,
+2 -5
View File
@@ -1,6 +1,6 @@
import { truncateWithMarker } from "@openclaw/normalization-core/utf16-slice";
/** Name, agent id, and payload text normalization helpers for cron service ops. */
import { normalizeOptionalAgentId } from "../../routing/session-key.js";
import { truncateUtf16Safe } from "../../utils.js";
import type { CronPayload } from "../types.js";
/** Normalizes a required cron job name and throws the public validation error when absent. */
@@ -16,10 +16,7 @@ export function normalizeRequiredName(raw: unknown) {
}
function truncateText(input: string, maxLen: number) {
if (input.length <= maxLen) {
return input;
}
return `${truncateUtf16Safe(input, Math.max(0, maxLen - 1)).trimEnd()}`;
return truncateWithMarker(input, maxLen, { marker: "…", reserve: 1, trimEnd: true });
}
/** Normalizes optional cron agent ids through the canonical session-key agent id rules. */
+1 -4
View File
@@ -1,4 +1,5 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty as normalizeErrorSignal } from "@openclaw/normalization-core/string-coerce";
import { isContextOverflowError } from "../agents/embedded-agent-helpers/context-overflow.js";
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
import {
@@ -88,10 +89,6 @@ const GATEWAY_ASSISTANT_ERROR_FALLBACK_TEXT = "The agent run failed before produ
const GATEWAY_ASSISTANT_CONTEXT_OVERFLOW_FALLBACK_TEXT =
"Context overflow: this conversation is too large for the model. Try /compact, use /new to start a fresh session, or retry the command with a tighter output limit.";
function normalizeErrorSignal(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function isContextOverflowErrorSignal(value: unknown): boolean {
if (typeof value !== "string") {
return false;
@@ -1,6 +1,7 @@
// CLI backend live probe helpers run cron/MCP/image probes through the gateway
// CLI backend and poll for externally visible live results.
import { randomUUID } from "node:crypto";
import { asNullableRecord as asLoopbackSchemaRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { renderCatFacePngBase64 } from "../../test/helpers/live-image-probe.js";
import { AUTOMATIONS_TOOL_NAME } from "../agents/tools/automations-tool-name.js";
@@ -126,12 +127,6 @@ function parsePositiveInt(value: string | undefined, fallback: number, name: str
return parsed;
}
function asLoopbackSchemaRecord(schema: unknown): Record<string, unknown> | null {
return schema && typeof schema === "object" && !Array.isArray(schema)
? (schema as Record<string, unknown>)
: null;
}
function assertLoopbackObjectSchemasHaveProperties(params: {
tools: LoopbackToolListEntry[];
expectedSchemaProbeToolName?: string;
+11 -17
View File
@@ -1,6 +1,7 @@
// Gateway webhook helpers for external hook dispatch into agents and wake flows.
import { randomUUID } from "node:crypto";
import type { IncomingMessage } from "node:http";
import type { Result } from "@openclaw/normalization-core/result";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -175,7 +176,7 @@ export function extractHookToken(req: IncomingMessage): string | undefined {
export async function readJsonBody(
req: IncomingMessage,
maxBytes: number,
): Promise<{ ok: true; value: unknown } | { ok: false; error: string }> {
): Promise<Result<unknown, string>> {
const result = await readJsonBodyWithLimit(req, { maxBytes, emptyObjectOnEmpty: true });
if (result.ok) {
return result;
@@ -209,9 +210,7 @@ export function normalizeHookHeaders(req: IncomingMessage) {
/** Validate a hook wake payload. */
export function normalizeWakePayload(
payload: Record<string, unknown>,
):
| { ok: true; value: { text: string; mode: "now" | "next-heartbeat" } }
| { ok: false; error: string } {
): Result<{ text: string; mode: "now" | "next-heartbeat" }, string> {
const normalizedText = normalizeOptionalString(payload.text) ?? "";
if (!normalizedText) {
return { ok: false, error: "text required" };
@@ -287,12 +286,10 @@ function normalizeHookAgentDelivery(params: {
channel: unknown;
to: unknown;
accountId: unknown;
}):
| {
ok: true;
value: Pick<HookAgentPayload, "deliver" | "channel" | "to" | "accountId" | "delivery">;
}
| { ok: false; error: string } {
}): Result<
Pick<HookAgentPayload, "deliver" | "channel" | "to" | "accountId" | "delivery">,
string
> {
const deliver = resolveHookDeliver(params.deliver);
if (!deliver) {
return {
@@ -449,7 +446,7 @@ export function resolveHookSessionKey(params: {
source: HookSessionKeySource;
sessionKey?: string;
idFactory?: () => string;
}): { ok: true; value: string } | { ok: false; error: string } {
}): Result<string, string> {
const requested = resolveSessionKey(params.sessionKey);
if (requested) {
if (
@@ -526,12 +523,9 @@ export function normalizeHookDispatchSessionKey(params: {
}
/** Validate and normalize a hook agent payload before policy/session resolution. */
export function normalizeAgentPayload(payload: Record<string, unknown>):
| {
ok: true;
value: HookAgentPayload;
}
| { ok: false; error: string } {
export function normalizeAgentPayload(
payload: Record<string, unknown>,
): Result<HookAgentPayload, string> {
const message = normalizeOptionalString(payload.message) ?? "";
if (!message) {
return { ok: false, error: "message required" };
+1 -6
View File
@@ -1,4 +1,5 @@
/** Connected node-hosted plugin tools available to agent tool resolution. */
import { asOptionalRecord as normalizeRecord } from "@openclaw/normalization-core/record-coerce";
import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js";
import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -44,12 +45,6 @@ function normalizeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function defaultParameters(): Record<string, unknown> {
return { type: "object", properties: {}, additionalProperties: true };
}
@@ -1,4 +1,5 @@
// Transcript persistence and source-reply rewrites shared by chat send and abort.
import { asOptionalRecord as transcriptEventRecord } from "@openclaw/normalization-core/record-coerce";
import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js";
import {
findTranscriptEvent,
@@ -67,12 +68,6 @@ export function assistantTranscriptScope(
};
}
function transcriptEventRecord(event: TranscriptEvent): Record<string, unknown> | undefined {
return event && typeof event === "object" && !Array.isArray(event)
? (event as Record<string, unknown>)
: undefined;
}
function transcriptEventId(event: TranscriptEvent): string | undefined {
const id = transcriptEventRecord(event)?.id;
return typeof id === "string" && id.trim().length > 0 ? id : undefined;
+2 -5
View File
@@ -1,6 +1,7 @@
// Host directory browsing for the new-session folder picker. operator.admin
// only (see core-descriptors): listing arbitrary host paths carries the same
// trust as starting a session with an explicit cwd.
import { safeParseJson } from "@openclaw/normalization-core";
import {
ErrorCodes,
errorShape,
@@ -14,11 +15,7 @@ import type { GatewayRequestHandlers } from "./types.js";
function parseNodePayload(payload: unknown, payloadJSON?: string | null): unknown {
if (payloadJSON) {
try {
return JSON.parse(payloadJSON) as unknown;
} catch {
return undefined;
}
return safeParseJson(payloadJSON);
}
return payload;
}
@@ -1,6 +1,7 @@
// Model list result building resolves visible model catalogs for an agent and
// strips runtime-only provider params before sending the browse API payload.
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { asPositiveSafeInteger as resolvePositiveSafeInteger } from "@openclaw/normalization-core/number-coercion";
import {
resolveAgentEffectiveModelPrimary,
resolveAgentWorkspaceDir,
@@ -79,10 +80,6 @@ function resolveModelsListView(params: Record<string, unknown>): ModelsListView
return view === "configured" || view === "provider-config" || view === "all" ? view : "default";
}
function resolvePositiveSafeInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
}
// Project explicitly onto the public protocol shape. Concrete route, base URL,
// auth, and cost facts stay private; runtime intent is attached separately.
function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry {
+2 -5
View File
@@ -1,3 +1,4 @@
import { safeParseJson } from "@openclaw/normalization-core";
import {
GATEWAY_CLIENT_CAPS,
hasGatewayClientCap,
@@ -68,11 +69,7 @@ function parseNodePayload(payload: unknown, payloadJSON?: string | null): unknow
if (!payloadJSON) {
return payload;
}
try {
return JSON.parse(payloadJSON) as unknown;
} catch {
return undefined;
}
return safeParseJson(payloadJSON);
}
async function stageNodeTerminalUpload(
+1 -4
View File
@@ -1,5 +1,6 @@
// Gateway session lifecycle state projection.
// Converts agent run lifecycle events into session row/store status updates.
import { normalizeOptionalString as normalizeLifecycleRunId } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { SessionRunStatus } from "../../packages/gateway-protocol/src/schema/sessions-row.js";
import { isAgentLifecycleYieldedWaiting } from "../agents/agent-lifecycle-parent-state.js";
@@ -245,10 +246,6 @@ export function deriveGatewaySessionLifecycleProjectionPatch(params: {
return patch;
}
function normalizeLifecycleRunId(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function isRestartRecoveryLifecycleEvent(params: {
entry?: Pick<SessionEntry, "restartRecoveryRuns"> | null;
event: Pick<LifecycleEventLike, "runId" | "lifecycleGeneration" | "data">;
+1 -4
View File
@@ -2,6 +2,7 @@
// Apple Watch cannot use generic WebSockets on-device, so node events use bounded HTTPS polls.
import { randomBytes, randomUUID } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { isRecord as isStringRecord } from "@openclaw/normalization-core/record-coerce";
import {
GATEWAY_CLIENT_IDS,
GATEWAY_CLIENT_MODES,
@@ -174,10 +175,6 @@ function resolveWatchClientAddress(
};
}
function isStringRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function trackResponseLifecycle(res: ServerResponse): ResponseLifecycle {
let aborted = false;
let settled = false;
+2 -5
View File
@@ -1,6 +1,7 @@
// Hook workspace helpers resolve hook roots and workspace-local hook files.
import fs from "node:fs";
import path from "node:path";
import { safeParseJson } from "@openclaw/normalization-core";
import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -44,11 +45,7 @@ function readHookPackageManifest(dir: string): HookPackageManifest | null {
if (raw === null) {
return null;
}
try {
return JSON.parse(raw) as HookPackageManifest;
} catch {
return null;
}
return (safeParseJson(raw) as HookPackageManifest | undefined) ?? null;
}
function resolvePackageHooks(manifest: HookPackageManifest): string[] {
+1 -4
View File
@@ -1,5 +1,6 @@
// Resolves the LAN host OpenClaw should advertise to nearby devices.
import { isRfc1918Ipv4Address } from "@openclaw/net-policy/ip";
import { normalizeLowercaseStringOrEmpty as normalizeInterfaceName } from "@openclaw/normalization-core/string-coerce";
import { runCommandWithTimeout as defaultRunCommandWithTimeout } from "../process/exec.js";
import {
listExternalInterfaceAddresses,
@@ -56,10 +57,6 @@ type RankedWindowsRouteRow = {
order: number;
};
function normalizeInterfaceName(name: unknown): string {
return typeof name === "string" ? name.trim().toLowerCase() : "";
}
function normalizeMetric(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
+1 -6
View File
@@ -1,4 +1,5 @@
// Shared owner-qualified ClawHub security verdict resolution.
import { asOptionalRecord as readObject } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import pLimit from "p-limit";
import {
@@ -76,12 +77,6 @@ function partitionCompatibleBatches(
return batches.map((batch) => batch.items);
}
function readObject(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readOptionalStringField(value: unknown, field: string): string | undefined {
return normalizeOptionalString(readObject(value)?.[field]);
}
+1 -4
View File
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { isRecord as isJsonObject } from "@openclaw/normalization-core/record-coerce";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -852,10 +853,6 @@ function createClawHubBodyLimitError(
);
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function optionalStringField(
source: Record<string, unknown>,
field: string,
+32
View File
@@ -2,6 +2,7 @@
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
cancelUnreadResponseBody,
readResponseTextPrefix,
readResponseTextSnippet,
readResponseWithLimit,
@@ -103,6 +104,37 @@ async function expectReadResponseWithLimitFailureCase(params: {
).rejects.toThrow(params.expectedError);
}
describe("cancelUnreadResponseBody", () => {
it("cancels unread bodies and ignores cancellation failures", async () => {
const cancel = vi.fn(() => {
throw new Error("already closed");
});
const response = new Response(makeStallingStream([], cancel));
await expect(cancelUnreadResponseBody(response)).resolves.toBeUndefined();
expect(cancel).toHaveBeenCalledOnce();
});
it("leaves consumed and absent bodies alone", async () => {
const cancel = vi.fn();
const response = new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("done"));
controller.close();
},
cancel,
}),
);
await response.text();
await cancelUnreadResponseBody(response);
await cancelUnreadResponseBody(undefined);
expect(cancel).not.toHaveBeenCalled();
});
});
describe("readResponseWithLimit", () => {
beforeEach(() => {
vi.useRealTimers();
+7
View File
@@ -10,6 +10,13 @@ import { parseStrictNonNegativeInteger } from "./parse-finite-number.js";
export { readChunkWithIdleTimeout } from "./http-response-body-timeout.js";
/** Cancels a response body only when no consumer has started reading it. */
export async function cancelUnreadResponseBody(response: Response | undefined): Promise<void> {
if (response && !response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
export const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
export const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
@@ -3,6 +3,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { isPassThroughRemoteMediaSource } from "@openclaw/media-core/media-source-url";
import { hasNonEmptyString as isNonEmptyMediaSource } from "@openclaw/normalization-core/string-coerce";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveDeliveryQueueMediaDir } from "../../config/paths.js";
import {
@@ -38,10 +39,6 @@ function resolveArtifactExtension(source: string): string {
return ARTIFACT_EXT_RE.test(extension) ? extension.toLowerCase() : "";
}
function isNonEmptyMediaSource(source: unknown): source is string {
return typeof source === "string" && Boolean(source.trim());
}
function payloadMediaSources(payload: ReplyPayload): string[] {
const sources: string[] = [];
if (isNonEmptyMediaSource(payload.mediaUrl)) {
+1 -6
View File
@@ -1,5 +1,6 @@
// Message-action runner normalizes tool params, resolves channel/target/media,
// applies policies, and dispatches send/poll/plugin actions.
import { asOptionalRecord as asResultRecord } from "@openclaw/normalization-core/record-coerce";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -284,12 +285,6 @@ export function getToolResult(
return "toolResult" in result ? result.toolResult : undefined;
}
function asResultRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function withSendNormalization(
result: MessageActionRunResult,
normalization?: MessageActionNormalization,
+3 -3
View File
@@ -3,9 +3,9 @@ import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { readProviderJsonResponse } from "../agents/provider-http-errors.js";
import { cancelUnreadResponseBody } from "./http-body.js";
import {
buildUsageHttpErrorSnapshot,
discardUsageResponseBody,
fetchJson,
parseUsageResetAt,
readUsageJson,
@@ -159,7 +159,7 @@ async function fetchClaudeWebUsage(
fetchFn,
);
if (!orgRes.ok) {
await discardUsageResponseBody(orgRes);
await cancelUnreadResponseBody(orgRes);
return null;
}
@@ -180,7 +180,7 @@ async function fetchClaudeWebUsage(
fetchFn,
);
if (!usageRes.ok) {
await discardUsageResponseBody(usageRes);
await cancelUnreadResponseBody(usageRes);
return null;
}
+2 -2
View File
@@ -1,9 +1,9 @@
// Fetches Codex provider usage windows.
import { resolveProviderRequestHeaders } from "../agents/provider-request-config.js";
import { cancelUnreadResponseBody } from "./http-body.js";
import { parseStrictFiniteNumber } from "./parse-finite-number.js";
import {
buildUsageHttpErrorSnapshot,
discardUsageResponseBody,
fetchJson,
readUsageJson,
} from "./provider-usage.fetch.shared.js";
@@ -89,7 +89,7 @@ export async function fetchCodexUsage(
);
if (!res.ok) {
await discardUsageResponseBody(res);
await cancelUnreadResponseBody(res);
return buildUsageHttpErrorSnapshot({
provider: "openai",
status: res.status,
+2 -2
View File
@@ -1,8 +1,8 @@
// Fetches and normalizes DeepSeek provider usage records.
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { cancelUnreadResponseBody } from "./http-body.js";
import {
buildUsageHttpErrorSnapshot,
discardUsageResponseBody,
fetchJson,
parseFiniteNumber,
readUsageJson,
@@ -75,7 +75,7 @@ export async function fetchDeepSeekUsage(
);
if (!res.ok) {
await discardUsageResponseBody(res);
await cancelUnreadResponseBody(res);
return buildUsageHttpErrorSnapshot({
provider: "deepseek",
status: res.status,
+2 -2
View File
@@ -2,9 +2,9 @@ import { expectDefined } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
// Fetches Gemini provider usage windows.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { cancelUnreadResponseBody } from "./http-body.js";
import {
buildUsageHttpErrorSnapshot,
discardUsageResponseBody,
fetchJson,
readUsageJson,
} from "./provider-usage.fetch.shared.js";
@@ -36,7 +36,7 @@ export async function fetchGeminiUsage(
);
if (!res.ok) {
await discardUsageResponseBody(res);
await cancelUnreadResponseBody(res);
return buildUsageHttpErrorSnapshot({
provider,
status: res.status,
+2 -2
View File
@@ -4,9 +4,9 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
import { readProviderJsonResponse } from "../agents/provider-http-errors.js";
import { isRecord } from "../utils.js";
import { readTrimmedStringAlias } from "../utils/string-readers.js";
import { cancelUnreadResponseBody } from "./http-body.js";
import {
buildUsageHttpErrorSnapshot,
discardUsageResponseBody,
fetchJson,
parseFiniteNumber,
} from "./provider-usage.fetch.shared.js";
@@ -544,7 +544,7 @@ export async function fetchMinimaxUsage(
);
if (!res.ok) {
await discardUsageResponseBody(res);
await cancelUnreadResponseBody(res);
return buildUsageHttpErrorSnapshot({
provider: "minimax",
status: res.status,
@@ -5,7 +5,6 @@ import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import {
buildUsageErrorSnapshot,
buildUsageHttpErrorSnapshot,
discardUsageResponseBody,
fetchJson,
parseFiniteNumber,
readUsageJson,
@@ -159,15 +158,6 @@ describe("provider usage fetch shared helpers", () => {
expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS);
});
it("cancels unread response bodies when discarding usage responses", async () => {
const response = new Response("not needed", { status: 429 });
const cancel = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined);
await discardUsageResponseBody(response);
expect(cancel).toHaveBeenCalledOnce();
});
it("maps configured status codes to token expired", () => {
const snapshot = buildUsageHttpErrorSnapshot({
provider: "openai",
-6
View File
@@ -23,12 +23,6 @@ export async function fetchJson(
return await fetchFn(url, { ...init, signal });
}
export async function discardUsageResponseBody(response: Response): Promise<void> {
if (!response.bodyUsed) {
await response.body?.cancel().catch(() => undefined);
}
}
export function parseFiniteNumber(value: unknown): number | undefined {
return parseFiniteNumberish(value);
}
+2 -2
View File
@@ -2,9 +2,9 @@
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { cancelUnreadResponseBody } from "./http-body.js";
import {
buildUsageHttpErrorSnapshot,
discardUsageResponseBody,
fetchJson,
parseUsageResetAt,
readUsageJson,
@@ -80,7 +80,7 @@ export async function fetchZaiUsage(
);
if (!res.ok) {
await discardUsageResponseBody(res);
await cancelUnreadResponseBody(res);
return buildUsageHttpErrorSnapshot({
provider: "zai",
status: res.status,
+2 -5
View File
@@ -1,4 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import { safeParseJson } from "@openclaw/normalization-core";
import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
@@ -356,11 +357,7 @@ function parseRequiredJson(value: string | null): unknown {
if (value === null) {
return undefined;
}
try {
return JSON.parse(value) as unknown;
} catch {
return undefined;
}
return safeParseJson(value);
}
function decodeRestartSentinelRow(row: {

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