refactor: consolidate core micro-helpers (#117825)

* refactor: centralize stable stringification

* refactor: reuse canonical record coercion

* refactor: reuse safe JSON parsing in cron storage

* refactor: centralize environment truthiness

* fix: enforce model scan and block reply timeouts

* refactor: consolidate signal-aware sleep helper

* fix: preserve plugin SDK sleep contract

* test: satisfy model scan timeout lint
This commit is contained in:
Peter Steinberger
2026-08-01 23:10:46 -07:00
committed by GitHub
parent df8cc5a458
commit 33ea3e16e9
71 changed files with 309 additions and 405 deletions
@@ -1,9 +1,14 @@
// Memory Host SDK module implements embeddings debug behavior.
import { parseBoolean } from "@openclaw/normalization-core/boolean-coercion";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
// Lightweight debug logging for memory embedding internals.
const debugEmbeddings = isTruthyEnvValue(process.env.OPENCLAW_DEBUG_MEMORY_EMBEDDINGS);
const normalizedDebugEmbeddings = normalizeLowercaseStringOrEmpty(
process.env.OPENCLAW_DEBUG_MEMORY_EMBEDDINGS,
);
const debugEmbeddings =
parseBoolean(normalizedDebugEmbeddings) ?? ["1", "on", "yes"].includes(normalizedDebugEmbeddings);
/** Write embedding debug metadata when OPENCLAW_DEBUG_MEMORY_EMBEDDINGS is enabled. */
export function debugEmbeddingsLog(message: string, meta?: Record<string, unknown>): void {
@@ -13,16 +18,3 @@ export function debugEmbeddingsLog(message: string, meta?: Record<string, unknow
const suffix = meta ? ` ${JSON.stringify(meta)}` : "";
console.warn(`${message}${suffix}`);
}
/** Parse common truthy env values for debug toggles. */
function isTruthyEnvValue(value?: string): boolean {
switch (normalizeLowercaseStringOrEmpty(value)) {
case "1":
case "on":
case "true":
case "yes":
return true;
default:
return false;
}
}
+6 -1
View File
@@ -79,6 +79,11 @@
"import": "./dist/stable-node-path.mjs",
"default": "./dist/stable-node-path.mjs"
},
"./stable-stringify": {
"types": "./dist/stable-stringify.d.mts",
"import": "./dist/stable-stringify.mjs",
"default": "./dist/stable-stringify.mjs"
},
"./utf16-slice": {
"types": "./dist/utf16-slice.d.mts",
"import": "./dist/utf16-slice.mjs",
@@ -86,7 +91,7 @@
}
},
"scripts": {
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/json-schema.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/stable-node-path.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/json-schema.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/stable-node-path.ts src/stable-stringify.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"libphonenumber-js": "1.13.9",
+1
View File
@@ -8,6 +8,7 @@ export * from "./format.js";
export * from "./json-coercion.js";
export * from "./number-coercion.js";
export * from "./record-coerce.js";
export * from "./stable-stringify.js";
export * from "./string-coerce.js";
export * from "./string-normalization.js";
export * from "./text-decoding.js";
@@ -2,10 +2,12 @@
* Regression coverage for deterministic unknown-value stringification.
* Verifies sorted keys, repeated references, cycles, binary data, and errors.
*/
import { sanitizeSurrogates } from "@openclaw/ai/internal/shared";
import { describe, expect, it } from "vitest";
import { stableStringify } from "./stable-stringify.js";
const sanitizeSurrogates = (text: string) =>
text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
describe("stableStringify", () => {
it("sorts object keys recursively", () => {
expect(stableStringify({ b: { d: 4, c: 3 }, a: 1 })).toBe('{"a":1,"b":{"c":3,"d":4}}');
@@ -3,7 +3,6 @@
* Serializes arbitrary values with deterministic key ordering and explicit
* handling for errors, binary data, bigint, non-finite numbers, and cycles.
*/
import { Buffer } from "node:buffer";
type StableStringNormalizer = (value: string) => string;
@@ -69,7 +68,7 @@ function stringifyObjectValue(
return stringifyStableValue(
{
type: "Uint8Array",
data: Buffer.from(value).toString("base64"),
data: encodeBase64(value),
},
stack,
normalizeString,
@@ -99,6 +98,14 @@ function stringifyObjectValue(
return `{${serializedFields.join(",")}}`;
}
function encodeBase64(value: Uint8Array): string {
let binary = "";
for (const byte of value) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function compareStableStrings(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
@@ -1,3 +1,4 @@
import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce";
import {
isOffsetInProtectedRanges,
type PlainTextToolCallNameMatcher,
@@ -103,10 +104,6 @@ type SuppressingPendingState = {
type PendingState = CandidatePendingState | SuppressingPendingState;
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
}
function eventContentIndex(event: Record<string, unknown>): number {
const index = event.contentIndex;
return typeof index === "number" && Number.isInteger(index) && index >= 0 ? index : 0;
@@ -120,7 +117,7 @@ function extractStandaloneCandidate(
message: unknown,
requireAssistantRole = false,
): StandalonePlainTextToolCallCandidate | undefined {
const record = asRecord(message);
const record = asOptionalObjectRecord(message);
if (!record || (requireAssistantRole && record.role !== "assistant")) {
return undefined;
}
@@ -132,7 +129,7 @@ function extractStandaloneCandidate(
}
const candidate: StandalonePlainTextToolCallCandidate = { text: "", parts: [] };
for (const [contentIndex, block] of record.content.entries()) {
const value = asRecord(block);
const value = asOptionalObjectRecord(block);
if (!value) {
return undefined;
}
@@ -397,7 +394,7 @@ function projectRangesOntoMessage(
const sourceToProjectedContentIndex = new Map<number, number>();
for (const [index, block] of record.content.entries()) {
const part = parts.get(index);
const blockRecord = asRecord(block);
const blockRecord = asOptionalObjectRecord(block);
if (!part || blockRecord?.type !== "text" || typeof blockRecord.text !== "string") {
sourceToProjectedContentIndex.set(index, content.length);
content.push(block);
@@ -422,7 +419,7 @@ export function projectScrubbedPlainTextToolCallMessage(params: {
resolveProtectedRanges?: PlainTextToolCallProtectedRangeResolver;
requireAssistantRole?: boolean;
}): PlainTextToolCallMessageProjection | undefined {
const record = asRecord(params.message);
const record = asOptionalObjectRecord(params.message);
const candidate = extractStandaloneCandidate(
params.message,
params.requireAssistantRole === true,
@@ -486,7 +483,7 @@ function resolvePartialProtectionCheck(params: {
resolveProtectedRanges: PlainTextToolCallProtectedRangeResolver;
}): ((offset: number) => boolean) | undefined {
const candidate = extractStandaloneCandidate(params.partial);
const record = asRecord(params.partial);
const record = asOptionalObjectRecord(params.partial);
if (!candidate || !record) {
return undefined;
}
@@ -500,7 +497,7 @@ function resolvePartialProtectionCheck(params: {
} else {
const part = candidate.parts.find((entry) => entry.contentIndex === params.contentIndex);
const block = Array.isArray(record.content)
? asRecord(record.content[params.contentIndex])
? asOptionalObjectRecord(record.content[params.contentIndex])
: undefined;
if (!part || block?.type !== "text" || typeof block.text !== "string") {
return undefined;
@@ -711,14 +708,14 @@ function projectedTextForEvent(
event: Record<string, unknown>,
projection: PlainTextToolCallMessageProjection,
): string | undefined {
const content = asRecord(projection.message)?.content;
const content = asOptionalObjectRecord(projection.message)?.content;
if (typeof content === "string") {
return content;
}
const projectedIndex = projection.sourceToProjectedContentIndex.get(eventContentIndex(event));
const block =
Array.isArray(content) && projectedIndex !== undefined
? asRecord(content[projectedIndex])
? asOptionalObjectRecord(content[projectedIndex])
: undefined;
return block?.type === "text" && typeof block.text === "string" ? block.text : undefined;
}
@@ -1088,7 +1085,7 @@ function orderByContentIndex(
): unknown[] {
const contentLength = Array.isArray(message.content) ? message.content.length : 0;
const order = (event: unknown) => {
const index = asRecord(event)?.contentIndex;
const index = asOptionalObjectRecord(event)?.contentIndex;
return typeof index === "number" &&
Number.isInteger(index) &&
index >= 0 &&
@@ -1223,7 +1220,7 @@ export async function* normalizePlainTextToolCallStreamEvents(
async function* normalizeEvents() {
for await (const sourceEvent of source) {
let record = asRecord(sourceEvent);
let record = asOptionalObjectRecord(sourceEvent);
if (!record) {
yield sourceEvent;
continue;
@@ -1411,7 +1408,7 @@ export async function* normalizePlainTextToolCallStreamEvents(
yield createSyntheticTextDelta(
visibleTemplate,
novelVisiblePrefix,
asRecord(visibleProjection?.message),
asOptionalObjectRecord(visibleProjection?.message),
);
}
}
@@ -1456,7 +1453,7 @@ export async function* normalizePlainTextToolCallStreamEvents(
createSyntheticTextDelta(
pending.template,
candidateText,
asRecord(record.partial),
asOptionalObjectRecord(record.partial),
),
{ ...incomingRecord, content: incoming },
];
@@ -1823,7 +1820,7 @@ export async function* normalizePlainTextToolCallStreamEvents(
}
}
for await (const event of normalizeEvents()) {
const record = asRecord(event);
const record = asOptionalObjectRecord(event);
if (record?.type === "text_delta" && typeof record.delta === "string") {
const key = eventKey(record);
const previous = emittedTextUnits.get(key) ?? 0;
+1 -1
View File
@@ -4,6 +4,7 @@
import crypto from "node:crypto";
import path from "node:path";
import { sanitizeSurrogates } from "@openclaw/ai/internal/shared";
import { stableStringify } from "@openclaw/normalization-core";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveUserPath } from "../utils.js";
@@ -12,7 +13,6 @@ import { safeJsonStringify } from "../utils/safe-json.js";
import { redactAgentDiagnosticPayload } from "./diagnostic-redaction.js";
import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js";
import type { AgentMessage, StreamFn } from "./runtime/index.js";
import { stableStringify } from "./stable-stringify.js";
import { buildAgentTraceBase } from "./trace-base.js";
// Payloads are redacted before JSONL output while stable digests preserve
+1 -1
View File
@@ -1,4 +1,5 @@
import { createHash, randomUUID } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { NODE_FS_LIST_DIR_COMMAND } from "../infra/node-commands.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
@@ -13,7 +14,6 @@ import {
} from "./code-mode-runtime.js";
import { readCodeModeSkill } from "./code-mode-skills.js";
import type { AgentToolUpdateCallback } from "./runtime/index.js";
import { stableStringify } from "./stable-stringify.js";
import { getSwarmRunByLaunchReplayKey, initSubagentRegistry } from "./subagent-registry.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
import {
+1 -1
View File
@@ -1,9 +1,9 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCodeModeNamespaceRuntime } from "./code-mode-namespaces.js";
import { resolveCodeModeConfig } from "./code-mode.js";
import { testing } from "./code-mode.test-support.js";
import { stableStringify } from "./stable-stringify.js";
import {
SWARM_CODE_MODE_IDEMPOTENCY_KEY,
SWARM_CODE_MODE_REQUEST_FINGERPRINT,
@@ -1,3 +1,4 @@
import { stableStringify } from "@openclaw/normalization-core";
/**
* Builds structured observations for embedded-agent API/text failures.
*/
@@ -12,7 +13,6 @@ import {
parseApiErrorInfo,
type ProviderRuntimeFailureKind,
} from "./embedded-agent-helpers.js";
import { stableStringify } from "./stable-stringify.js";
const MAX_OBSERVATION_INPUT_CHARS = 64_000;
const MAX_FINGERPRINT_MESSAGE_CHARS = 8_000;
@@ -1,3 +1,4 @@
import { stableStringify } from "@openclaw/normalization-core";
/**
* Converts raw provider/transport errors into concise user-facing copy.
*/
@@ -27,7 +28,6 @@ import { findCodeRegions } from "../../shared/text/code-regions.js";
import { stripFinalTags } from "../../shared/text/final-tags.js";
import { formatExecDeniedUserMessage } from "../exec-approval-result.js";
import { stripInternalRuntimeContext } from "../internal-runtime-context.js";
import { stableStringify } from "../stable-stringify.js";
import {
isBillingErrorMessage,
isOverloadedErrorMessage,
@@ -7,6 +7,7 @@ import {
stripSystemPromptCacheBoundary,
} from "@openclaw/ai/internal/shared";
import { mergeTransportHeaders, sanitizeTransportPayloadText } from "@openclaw/ai/transports";
import { stableStringify } from "@openclaw/normalization-core";
import {
asDateTimestampMs,
isFutureDateTimestampMs,
@@ -28,7 +29,6 @@ import { resolveProviderRequestHeaders } from "../provider-request-config.js";
import { buildGuardedModelFetch } from "../provider-transport-fetch.js";
import type { StreamFn } from "../runtime/index.js";
import { isSessionWriteLockAcquireError } from "../session-write-lock-error.js";
import { stableStringify } from "../stable-stringify.js";
import { log } from "./logger.js";
import { isGooglePromptCacheEligible, resolveCacheRetention } from "./prompt-cache-retention.js";
import { EmbeddedAttemptSessionTakeoverError } from "./run/attempt.session-lock.js";
@@ -6,8 +6,8 @@ import {
sortPromptCacheToolsByName,
splitSystemPromptCacheBoundary,
} from "@openclaw/ai/internal/shared";
import { stableStringify } from "@openclaw/normalization-core";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { stableStringify } from "../stable-stringify.js";
import type { NormalizedUsage } from "../usage.js";
type PromptCacheChangeCode =
@@ -1,9 +1,9 @@
import { Buffer } from "node:buffer";
import crypto from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Model } from "openclaw/plugin-sdk/llm";
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
import { stableStringify } from "../stable-stringify.js";
type ProviderPromptSnapshot = {
scopeDigest: string;
@@ -1,3 +1,4 @@
import { stableStringify } from "@openclaw/normalization-core";
import { formatContextJsonBlock } from "../../../auto-reply/reply/channel-prompt-context.js";
import { markInboundContextLabel } from "../../../auto-reply/reply/inbound-context-marker.js";
import {
@@ -5,7 +6,6 @@ import {
INTER_SESSION_PROMPT_PREFIX_BASE,
} from "../../../sessions/input-provenance.js";
import type { AgentMessage } from "../../runtime/index.js";
import { stableStringify } from "../../stable-stringify.js";
import { isRunnerToolCallBlockType } from "./attempt.tool-call-block-type.js";
export type UserTranscriptContext = {
@@ -1,4 +1,4 @@
import { stableStringify } from "../stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { normalizeToolName } from "../tool-policy.js";
import { codexNativeHookRelayResponseCodec } from "./native-hook-relay-response-codec.js";
import type {
@@ -1,3 +1,4 @@
import { stableStringify } from "@openclaw/normalization-core";
import {
getAgentToolResultMiddlewareMatcherScope,
listAgentToolResultMiddlewares,
@@ -12,7 +13,6 @@ import {
hasBeforeToolCallPolicy,
runBeforeToolCallHook,
} from "../agent-tools.before-tool-call.js";
import { stableStringify } from "../stable-stringify.js";
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
import { payloadTextResult } from "../tools/common.js";
import { runAgentHarnessAfterToolCallHook } from "./hook-helpers.js";
@@ -1,3 +1,4 @@
import { stableStringify } from "@openclaw/normalization-core";
/**
* Shared media generation task status and duplicate-guard helpers.
*
@@ -12,7 +13,6 @@ import {
import { listFreshTasksForOwnerKey } from "../tasks/runtime-internal.js";
import type { TaskRecord } from "../tasks/task-registry.types.js";
import { buildSessionAsyncTaskStatusDetails } from "./session-async-task-status.js";
import { stableStringify } from "./stable-stringify.js";
/** Marks media as ready while requester delivery is still being confirmed. */
export const MEDIA_GENERATION_DELIVERING_COMPLETION_PROGRESS =
+5 -12
View File
@@ -179,16 +179,9 @@ describe("scanOpenRouterModels", () => {
it("applies the scan timeout before the OpenRouter catalog responds", async () => {
vi.useFakeTimers();
const fetchImpl: typeof fetch = async (_input, init) =>
await new Promise<Response>((_resolve, reject) => {
const signal = typeof init === "object" && init ? init.signal : undefined;
if (signal?.aborted) {
reject(new Error("catalog aborted"));
return;
}
signal?.addEventListener("abort", () => reject(new Error("catalog aborted")), {
once: true,
});
const fetchImpl: typeof fetch = async () =>
await new Promise<Response>(() => {
// Deliberately ignore cancellation to prove the timeout race settles independently.
});
const scan = expect(
@@ -197,7 +190,7 @@ describe("scanOpenRouterModels", () => {
probe: false,
timeoutMs: 1,
}),
).rejects.toThrow(/catalog aborted/);
).rejects.toThrow(/OpenRouter model scan timed out/);
await vi.advanceTimersByTimeAsync(1);
await scan;
@@ -253,7 +246,7 @@ describe("scanOpenRouterModels", () => {
const scan = expect(
scanOpenRouterModels({ fetchImpl, probe: false, timeoutMs }),
).rejects.toThrow(/aborted/i);
).rejects.toThrow(/timed out/i);
await vi.advanceTimersByTimeAsync(timeoutMs - 1);
expect(chunkCount).toBe(3);
+87 -88
View File
@@ -24,6 +24,7 @@ import { formatErrorMessage } from "../infra/errors.js";
import { readResponseWithLimit } from "../infra/http-body.js";
import "../llm/ai-transport-host.js";
import type { Context, Model, Tool } from "../llm/types.js";
import { withTimeout } from "../node-host/with-timeout.js";
import { inferParamBFromIdOrName } from "../shared/model-param-b.js";
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
@@ -181,19 +182,6 @@ function isFreeOpenRouterModel(entry: OpenRouterModelMeta): boolean {
return entry.pricing.prompt === 0 && entry.pricing.completion === 0;
}
async function withTimeout<T>(
timeoutMs: number,
fn: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(controller.abort.bind(controller), timeoutMs);
try {
return await fn(controller.signal);
} finally {
clearTimeout(timer);
}
}
// Reads the OpenRouter /models success body under a byte cap before JSON.parse.
// The success path was previously buffered with an unbounded res.json(); a faulty
// or hostile provider could stream an effectively endless document and exhaust
@@ -222,74 +210,79 @@ async function fetchOpenRouterModels(
try {
// fetch resolves after headers, so keep the shared timeout active until
// the provider-controlled catalog body has been consumed.
return await withTimeout(timeoutMs, async (signal) => {
res = await fetchImpl(OPENROUTER_MODELS_URL, {
headers: { Accept: "application/json" },
signal,
});
if (!res.ok) {
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
}
const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown };
const entries = Array.isArray(payload.data) ? payload.data : [];
return await withTimeout(
async (signal) => {
res = await fetchImpl(OPENROUTER_MODELS_URL, {
headers: { Accept: "application/json" },
signal,
});
if (!res.ok) {
throw new Error(`OpenRouter /models failed: HTTP ${res.status}`);
}
const payload = (await readOpenRouterModelsJson(res, timeoutMs)) as { data?: unknown };
const entries = Array.isArray(payload.data) ? payload.data : [];
return entries
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const obj = entry as Record<string, unknown>;
const id = normalizeOptionalString(obj.id) ?? "";
if (!id) {
return null;
}
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
return entries
.map((entry) => {
if (!entry || typeof entry !== "object") {
return null;
}
const obj = entry as Record<string, unknown>;
const id = normalizeOptionalString(obj.id) ?? "";
if (!id) {
return null;
}
const name = typeof obj.name === "string" && obj.name.trim() ? obj.name.trim() : id;
const contextLength =
typeof obj.context_length === "number" && Number.isFinite(obj.context_length)
? obj.context_length
: null;
const maxCompletionTokens =
typeof obj.max_completion_tokens === "number" &&
Number.isFinite(obj.max_completion_tokens)
? obj.max_completion_tokens
: typeof obj.max_output_tokens === "number" && Number.isFinite(obj.max_output_tokens)
? obj.max_output_tokens
const contextLength =
typeof obj.context_length === "number" && Number.isFinite(obj.context_length)
? obj.context_length
: null;
const supportedParameters = Array.isArray(obj.supported_parameters)
? normalizeStringEntries(
obj.supported_parameters.filter((value) => typeof value === "string"),
)
: [];
const maxCompletionTokens =
typeof obj.max_completion_tokens === "number" &&
Number.isFinite(obj.max_completion_tokens)
? obj.max_completion_tokens
: typeof obj.max_output_tokens === "number" &&
Number.isFinite(obj.max_output_tokens)
? obj.max_output_tokens
: null;
const supportedParametersCount = supportedParameters.length;
const supportsToolsMeta = supportedParameters.includes("tools");
const supportedParameters = Array.isArray(obj.supported_parameters)
? normalizeStringEntries(
obj.supported_parameters.filter((value) => typeof value === "string"),
)
: [];
const modality =
typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null;
const supportedParametersCount = supportedParameters.length;
const supportsToolsMeta = supportedParameters.includes("tools");
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
const pricing = parseOpenRouterPricing(obj.pricing);
const modality =
typeof obj.modality === "string" && obj.modality.trim() ? obj.modality.trim() : null;
return {
id,
name,
contextLength,
maxCompletionTokens,
supportedParameters,
supportedParametersCount,
supportsToolsMeta,
modality,
inferredParamB,
createdAtMs,
pricing,
} satisfies OpenRouterModelMeta;
})
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
});
const inferredParamB = inferParamBFromIdOrName(`${id} ${name}`);
const createdAtMs = normalizeCreatedAtMs(obj.created_at);
const pricing = parseOpenRouterPricing(obj.pricing);
return {
id,
name,
contextLength,
maxCompletionTokens,
supportedParameters,
supportedParametersCount,
supportsToolsMeta,
modality,
inferredParamB,
createdAtMs,
pricing,
} satisfies OpenRouterModelMeta;
})
.filter((entry): entry is OpenRouterModelMeta => Boolean(entry));
},
timeoutMs,
"OpenRouter model scan",
);
} finally {
if (res && !res.bodyUsed) {
await res.body?.cancel().catch(() => undefined);
@@ -315,14 +308,17 @@ async function probeTool(
};
const startedAt = Date.now();
try {
const message = await withTimeout(timeoutMs, (signal) =>
complete(model, context, {
apiKey,
maxTokens: 256,
temperature: 0,
toolChoice: "required",
signal,
} satisfies OpenAICompletionsOptions),
const message = await withTimeout(
(signal) =>
complete(model, context, {
apiKey,
maxTokens: 256,
temperature: 0,
toolChoice: "required",
signal,
} satisfies OpenAICompletionsOptions),
timeoutMs,
"model tool probe",
);
const hasToolCall = message.content.some((block) => block.type === "toolCall");
@@ -364,13 +360,16 @@ async function probeImage(
};
const startedAt = Date.now();
try {
await withTimeout(timeoutMs, (signal) =>
complete(model, context, {
apiKey,
maxTokens: 16,
temperature: 0,
signal,
} satisfies OpenAICompletionsOptions),
await withTimeout(
(signal) =>
complete(model, context, {
apiKey,
maxTokens: 16,
temperature: 0,
signal,
} satisfies OpenAICompletionsOptions),
timeoutMs,
"model image probe",
);
return { ok: true, latencyMs: Date.now() - startedAt };
} catch (err) {
+1 -1
View File
@@ -6,6 +6,7 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { stableStringify } from "@openclaw/normalization-core";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
getRuntimeConfig,
@@ -44,7 +45,6 @@ import {
resolvePluginModelCatalogOwnerPluginId,
type PersistedPluginModelCatalog,
} from "./plugin-model-catalog.js";
import { stableStringify } from "./stable-stringify.js";
type PreparedOpenClawModelsJsonSource = ModelsJsonReadyResult & {
fingerprint: string;
+1 -1
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { performance } from "node:perf_hooks";
import type { ConfiguredModelRef } from "@openclaw/model-catalog-core/configured-model-refs";
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { stableStringify } from "@openclaw/normalization-core";
import type { PreparedMessageToolCatalog } from "../channels/plugins/message-action-discovery.js";
import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
import { sha256Base64Url } from "../infra/crypto-digest.js";
@@ -60,7 +61,6 @@ import type {
import { loadAgentRuntimePluginRegistryHandle } from "./runtime-plugins.js";
import type { AuthStorage, AuthStorageData } from "./sessions/auth-storage.js";
import type { ModelRegistry } from "./sessions/model-registry.js";
import { stableStringify } from "./stable-stringify.js";
const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000;
const fullModelCatalogSnapshots = new WeakSet<ModelCatalogSnapshot>();
+1 -1
View File
@@ -4,6 +4,7 @@
* Watches recent tool history for repeated no-progress patterns and circuit-breaker thresholds.
*/
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import {
normalizeNullableString as nonEmptyStringField,
normalizeOptionalString as normalizeRunId,
@@ -13,7 +14,6 @@ import type { SessionState, ToolCallRecord } from "../logging/diagnostic-session
import { createSubsystemLogger } from "../logging/subsystem.js";
import { isPlainObject } from "../utils.js";
import { isMessagingToolSendAction } from "./embedded-agent-messaging.js";
import { stableStringify } from "./stable-stringify.js";
import {
buildArgumentChurnWarning,
getArgumentChurnNoProgressStreak,
+1 -1
View File
@@ -5,6 +5,7 @@
* union with approval assertions and the audit log.
*/
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { Type } from "typebox";
import type { RuntimeEnv } from "../../runtime.js";
import {
@@ -14,7 +15,6 @@ import {
} from "../../system-agent/operations.js";
import { validateSystemAgentPluginInstallSpec } from "../../system-agent/plugin-install.js";
import { stringEnum } from "../schema/typebox.js";
import { stableStringify } from "../stable-stringify.js";
import { textResult, ToolInputError, readStringParam, type AnyAgentTool } from "./common.js";
export type SystemAgentToolOptions = {
@@ -55,6 +55,22 @@ describe("createBlockReplyContentKey", () => {
});
describe("createBlockReplyPipeline dedup with threading", () => {
it("keeps an un-aborted delivery signal when timeouts are disabled", async () => {
let deliverySignal: AbortSignal | undefined;
const pipeline = createBlockReplyPipeline({
onBlockReply: async (_payload, options) => {
deliverySignal = options?.abortSignal;
},
timeoutMs: 0,
});
pipeline.enqueue({ text: "response text" });
await pipeline.flush({ force: true });
expect(deliverySignal).toBeDefined();
expect(deliverySignal?.aborted).toBe(false);
});
it("does not count reasoning or commentary as a terminal reply", async () => {
const pipeline = createBlockReplyPipeline({
onBlockReply: async () => {},
+12 -32
View File
@@ -5,6 +5,7 @@ import {
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { logVerbose } from "../../globals.js";
import { withTimeout } from "../../node-host/with-timeout.js";
import { getReplyPayloadMetadata, isReplyPayloadStatusNotice } from "../reply-payload.js";
import type { ReplyPayload } from "../types.js";
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
@@ -79,27 +80,6 @@ export function createBlockReplyContentKey(payload: ReplyPayload): string {
});
}
const withTimeout = async <T>(
promise: Promise<T>,
timeoutMs: number,
timeoutError: Error,
): Promise<T> => {
if (!timeoutMs || timeoutMs <= 0) {
return promise;
}
let timer: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(timeoutError), timeoutMs);
});
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
};
function resolveBlockReplyTimeoutMs(timeoutMs: number): number {
return clampPositiveTimerTimeoutMs(timeoutMs) ?? 0;
}
@@ -158,22 +138,23 @@ export function createBlockReplyPipeline(params: {
pendingKeys.add(payloadKey);
// Preserve outbound order by chaining sends; abort after timeout to avoid stale blocks.
const timeoutError = new Error(`block reply delivery timed out after ${timeoutMs}ms`);
const abortController = new AbortController();
const fallbackAbortController = new AbortController();
let timeoutSignal: AbortSignal | undefined;
sendChain = sendChain
.then(async () => {
if (aborted) {
return false;
}
await withTimeout(
Promise.resolve(
onBlockReply(payload, {
abortSignal: abortController.signal,
async (signal) => {
timeoutSignal = signal;
await onBlockReply(payload, {
abortSignal: signal ?? fallbackAbortController.signal,
timeoutMs,
}),
),
timeoutMs,
timeoutError,
});
},
timeoutMs || undefined,
"block reply delivery",
);
return true;
})
@@ -208,8 +189,7 @@ export function createBlockReplyPipeline(params: {
}
})
.catch((err: unknown) => {
if (err === timeoutError) {
abortController.abort();
if (timeoutSignal?.aborted) {
aborted = true;
if (!didLogTimeout) {
didLogTimeout = true;
+1 -1
View File
@@ -1,8 +1,8 @@
import { createHash } from "node:crypto";
import { expectDefined } from "@openclaw/normalization-core";
import { stableStringify } from "@openclaw/normalization-core";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { runAgentHarnessBeforeMessageWriteHook } from "../../../agents/harness/hook-helpers.js";
import { stableStringify } from "../../../agents/stable-stringify.js";
import { normalizeChatType } from "../../../channels/chat-type.js";
import { resolveStorePath } from "../../../config/sessions.js";
import { loadSessionEntryReadOnly } from "../../../config/sessions/session-accessor.js";
+1 -1
View File
@@ -1,9 +1,9 @@
// Applies the package, agent, workspace, and managed-file slices of a consented Claw add plan.
import { lstat, mkdir, rmdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { stableStringify } from "@openclaw/normalization-core";
import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js";
import { listAgentEntries } from "../agents/agent-scope.js";
import { stableStringify } from "../agents/stable-stringify.js";
import { transformConfigFileWithRetry } from "../config/config.js";
import type { AgentConfig } from "../config/types.agents.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import {
CLAW_CRON_REF_SCHEMA_VERSION,
+1 -1
View File
@@ -1,7 +1,7 @@
// Claw doctor diagnostics project the lifecycle ownership ledger into health findings.
import { createHash } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { listConfiguredMcpServers } from "../config/mcp-config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveDefaultCronStaggerMs } from "../cron/stagger.js";
+1 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { listAgentEntries } from "../agents/agent-scope.js";
import { stableStringify } from "../agents/stable-stringify.js";
import { getRuntimeConfig } from "../config/config.js";
import type { AgentConfig } from "../config/types.agents.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { getRuntimeConfig } from "../config/config.js";
import { listConfiguredMcpServers, unsetConfiguredMcpServer } from "../config/mcp-config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+1 -1
View File
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
import { lstat, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js";
import { assertNoSymlinkParents } from "../infra/fs-safe-advanced.js";
import { FsSafeError, root as fsSafeRoot, type Root } from "../infra/fs-safe.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { canonicalizeConfiguredMcpServer } from "../config/mcp-config-normalize.js";
import { listConfiguredMcpServers, setConfiguredMcpServer } from "../config/mcp-config.js";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import {
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
+1 -1
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { preflightPluginInstall } from "../plugins/plugin-install-preflight.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import {
+1 -1
View File
@@ -1,7 +1,7 @@
// Persists the root ownership record for one Claw-created agent and workspace.
import { createHash } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
+1 -1
View File
@@ -654,4 +654,4 @@ describe("applyClawUpdatePlan", () => {
});
});
import { createHash } from "node:crypto";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
+1 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { listAgentEntries } from "../agents/agent-scope.js";
import { stableStringify } from "../agents/stable-stringify.js";
import { transformConfigFileWithRetry } from "../config/config.js";
import type { AgentConfig } from "../config/types.agents.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+1 -1
View File
@@ -1,8 +1,8 @@
// Builds field-level capability change summaries for Claw update previews.
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import { listAgentEntries, toAgentEntriesRecord } from "../agents/agent-scope.js";
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
import { stableStringify } from "../agents/stable-stringify.js";
import { expandToolGroups, resolveToolProfilePolicy } from "../agents/tool-policy-shared.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
+1 -1
View File
@@ -1,9 +1,9 @@
import { createHash } from "node:crypto";
import { readFile, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { stableStringify } from "@openclaw/normalization-core";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { stableStringify } from "../agents/stable-stringify.js";
import type { McpServerConfig } from "../config/types.mcp.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
+1 -1
View File
@@ -1,7 +1,7 @@
// Builds read-only, agent-centric Claw update plans from grouped manifests and ownership state.
import { createHash } from "node:crypto";
import { lstat } from "node:fs/promises";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { normalizeConfiguredMcpServers } from "../config/mcp-config-normalize.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { root as fsSafeRoot } from "../infra/fs-safe.js";
+1 -1
View File
@@ -1,10 +1,10 @@
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { stableStringify } from "@openclaw/normalization-core";
import {
listAgentEntries,
listAgentIds,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope-config.js";
import { stableStringify } from "../agents/stable-stringify.js";
import {
applyClawAddPlan,
CLAW_ADD_RESULT_SCHEMA_VERSION,
+2 -6
View File
@@ -242,10 +242,6 @@ export async function collectMacGatewayPlatformWarnings(
return warnings;
}
function isTruthyEnvValue(value: string | undefined): boolean {
return Boolean(normalizeOptionalString(value));
}
function isTmpCompileCachePath(cachePath: string): boolean {
const normalized = cachePath.trim().replace(/\/+$/, "");
return (
@@ -296,7 +292,7 @@ export function noteStartupOptimizationHints(
);
}
if (isTruthyEnvValue(disableCompileCache)) {
if (disableCompileCache) {
lines.push("- NODE_DISABLE_COMPILE_CACHE is set; startup compile cache is disabled.");
}
@@ -315,7 +311,7 @@ export function noteStartupOptimizationHints(
" export NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache",
" mkdir -p /var/tmp/openclaw-compile-cache",
" export OPENCLAW_NO_RESPAWN=1",
isTruthyEnvValue(disableCompileCache) ? " unset NODE_DISABLE_COMPILE_CACHE" : undefined,
disableCompileCache ? " unset NODE_DISABLE_COMPILE_CACHE" : undefined,
].filter((line): line is string => Boolean(line));
noteFn([...lines, ...suggestions].join("\n"), "Startup optimization");
+1 -1
View File
@@ -1,6 +1,6 @@
import { expectDefined } from "@openclaw/normalization-core";
// Normalizes MCP server config for runtime launch and validation.
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { markClawMcpServerIndependentlyOwned } from "../state/claw-mcp-adoption.js";
import { isRecord } from "../utils.js";
import { readSourceConfigSnapshot } from "./io.js";
+1 -1
View File
@@ -1,5 +1,5 @@
/** Opaque revision token for cron configuration, excluding scheduler-maintained state. */
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { sha256Base64Url } from "../infra/crypto-digest.js";
import { projectCronJobThroughStorageCodec } from "./store/row-codec.js";
import type { CronJob } from "./types.js";
+1 -1
View File
@@ -1,4 +1,4 @@
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { sha256Base64Url } from "../infra/crypto-digest.js";
import type { CronJob } from "./types.js";
-4
View File
@@ -68,10 +68,6 @@ export function formatUnknownError(error: unknown): string {
return String(error);
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object";
}
export function normalizeToolName(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
+14 -12
View File
@@ -1,4 +1,5 @@
/** Builds bounded, redacted diagnostics for cron run logs and UI surfaces. */
import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { isToolAllowedByPolicyName } from "../agents/tool-policy-match.js";
import { normalizeToolName as normalizePolicyToolName } from "../agents/tool-policy.js";
@@ -6,7 +7,6 @@ import { getReplyPayloadMetadata } from "../auto-reply/reply-payload.js";
import { redactSensitiveText } from "../logging/redact.js";
import {
formatUnknownError,
isRecord,
normalizeCronRunDiagnosticSummary,
normalizeCronRunDiagnostics as normalizeCronRunDiagnosticsValue,
normalizeExitCode,
@@ -165,16 +165,17 @@ function createCronRunDiagnosticsFromExecDetails(
finalStatus?: "ok" | "error" | "skipped";
},
): CronRunDiagnostics | undefined {
if (!isRecord(details)) {
const record = asOptionalObjectRecord(details);
if (!record) {
return undefined;
}
const status = typeof details.status === "string" ? details.status : undefined;
const exitCode = normalizeExitCode(details.exitCode);
const status = typeof record.status === "string" ? record.status : undefined;
const exitCode = normalizeExitCode(record.exitCode);
const relevant = status === "failed" || (typeof exitCode === "number" && exitCode !== 0);
if (!relevant) {
return undefined;
}
const aggregated = normalizeOptionalString(details.aggregated);
const aggregated = normalizeOptionalString(record.aggregated);
const message = aggregated
? tailText(aggregated, EXEC_DIAGNOSTIC_TAIL_CHARS)
: typeof exitCode === "number"
@@ -203,20 +204,21 @@ function createCronRunDiagnosticsFromToolPayload(
payload: unknown,
opts?: { nowMs?: () => number; finalStatus?: "ok" | "error" | "skipped" },
): CronRunDiagnostics | undefined {
if (!isRecord(payload)) {
const record = asOptionalObjectRecord(payload);
if (!record) {
return undefined;
}
const toolName = normalizeToolName(payload.toolName) ?? normalizeToolName(payload.name);
const detailsDiagnostics = createCronRunDiagnosticsFromExecDetails(payload.details, {
const toolName = normalizeToolName(record.toolName) ?? normalizeToolName(record.name);
const detailsDiagnostics = createCronRunDiagnosticsFromExecDetails(record.details, {
nowMs: opts?.nowMs,
toolName,
finalStatus: opts?.finalStatus,
});
const isError = payload.isError === true;
const text = typeof payload.text === "string" ? payload.text : undefined;
const isError = record.isError === true;
const text = typeof record.text === "string" ? record.text : undefined;
const isNonTerminalToolWarning =
opts?.finalStatus === "ok" &&
getReplyPayloadMetadata(payload)?.nonTerminalToolErrorWarning === true;
getReplyPayloadMetadata(record)?.nonTerminalToolErrorWarning === true;
const textDiagnostics =
isError && text
? createCronRunDiagnosticsFromError("tool", text, {
@@ -233,7 +235,7 @@ export function createCronRunDiagnosticsFromAgentResult(
result: unknown,
opts?: { nowMs?: () => number; finalStatus?: "ok" | "error" | "skipped" },
): CronRunDiagnostics | undefined {
const record = isRecord(result) ? result : {};
const record = asOptionalObjectRecord(result) ?? {};
const meta =
record.meta && typeof record.meta === "object" ? (record.meta as Record<string, unknown>) : {};
const diagnostics: Array<CronRunDiagnostics | undefined> = [];
+4 -4
View File
@@ -1,11 +1,11 @@
/** SQLite column codec for cron payload variants. */
import { safeParseJson } from "@openclaw/normalization-core";
import type { CronPayload } from "../types.js";
import {
booleanToInteger,
integerToBoolean,
normalizeNumber,
parseJsonArray,
parseJsonValue,
serializeJson,
} from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
@@ -40,14 +40,14 @@ function payloadToolAllowFromRow(
}
function parseExternalContentSource(raw: string | null): "gmail" | "webhook" | undefined {
const parsed = raw ? parseJsonValue<unknown>(raw, undefined) : undefined;
const parsed = raw ? safeParseJson(raw) : undefined;
return parsed === "gmail" || parsed === "webhook" ? parsed : undefined;
}
function parseCommandPayloadMessage(
raw: string | null,
): Omit<Extract<CronPayload, { kind: "command" }>, "kind" | "timeoutSeconds"> | null {
const parsed = raw ? parseJsonValue<unknown>(raw, undefined) : undefined;
const parsed = raw ? safeParseJson(raw) : undefined;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
@@ -92,7 +92,7 @@ function parseCommandPayloadMessage(
function parseScriptPayloadMessage(
raw: string | null,
): Omit<Extract<CronPayload, { kind: "script" }>, "kind" | "timeoutSeconds"> | null {
const parsed = raw ? parseJsonValue<unknown>(raw, undefined) : undefined;
const parsed = raw ? safeParseJson(raw) : undefined;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
+8 -11
View File
@@ -1,6 +1,7 @@
/** Converts cron jobs between public store shape and normalized SQLite rows. */
import type { DatabaseSync } from "node:sqlite";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { safeParseJson } from "@openclaw/normalization-core";
import { asOptionalObjectRecord, isRecord } from "@openclaw/normalization-core/record-coerce";
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js";
@@ -12,12 +13,7 @@ import type { CronJob, CronJobState, CronPacing, CronSchedule, CronStoreFile } f
import { bindDeliveryColumns, deliveryFromRow } from "./delivery-codec.js";
import { bindFailureAlertColumns, failureAlertFromRow } from "./failure-alert-codec.js";
import { bindPayloadColumns, payloadFromRow } from "./payload-codec.js";
import {
booleanToInteger,
integerToBoolean,
normalizeNumber,
parseJsonObject,
} from "./scalar-codec.js";
import { booleanToInteger, integerToBoolean, normalizeNumber } from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
import { getCronStoreKysely } from "./schema.js";
import { bindStateColumns, stateFromRow } from "./state-codec.js";
@@ -252,7 +248,7 @@ function scheduleFromRow(row: CronJobRow): CronSchedule | null {
};
}
if (row.schedule_kind === "stream") {
const schedule = parseJsonObject<Record<string, unknown>>(row.job_json, {}).schedule;
const schedule = asOptionalObjectRecord(safeParseJson(row.job_json))?.schedule;
if (!isRecord(schedule) || schedule.kind !== "stream" || !Array.isArray(schedule.command)) {
return null;
}
@@ -262,7 +258,7 @@ function scheduleFromRow(row: CronJobRow): CronSchedule | null {
}
function pacingFromRow(row: CronJobRow): CronPacing | undefined {
const pacing = parseJsonObject<Record<string, unknown>>(row.job_json, {}).pacing;
const pacing = asOptionalObjectRecord(safeParseJson(row.job_json))?.pacing;
if (!isRecord(pacing) || Array.isArray(pacing)) {
return undefined;
}
@@ -273,7 +269,7 @@ function pacingFromRow(row: CronJobRow): CronPacing | undefined {
}
function rowToCronJob(row: CronJobRow): CronJob | null {
const jobJson = parseJsonObject<Record<string, unknown>>(row.job_json, {});
const jobJson = asOptionalObjectRecord(safeParseJson(row.job_json)) ?? {};
const jsonOwner = isRecord(jobJson.owner) ? jobJson.owner : undefined;
const ownerAccountId = normalizeOptionalAccountId(
typeof jsonOwner?.accountId === "string" ? jsonOwner.accountId : undefined,
@@ -480,7 +476,8 @@ export function loadedCronStoreFromRows(rows: CronJobRow[]): LoadedCronStore {
for (const [index, row] of rows.entries()) {
const job = rowToCronJob(row);
const configJob = mergeFailureDestinationProjection(
parseJsonObject<Record<string, unknown>>(row.job_json, job ? stripJobRuntimeFields(job) : {}),
asOptionalObjectRecord(safeParseJson(row.job_json)) ??
(job ? stripJobRuntimeFields(job) : {}),
job,
);
const runtimeEntry = {
+2 -20
View File
@@ -1,24 +1,6 @@
import { safeParseJson } from "@openclaw/normalization-core";
import { normalizeSqliteNumber } from "../../infra/sqlite-number.js";
/** Parses a JSON object column, returning the fallback for malformed or non-object values. */
export function parseJsonObject<T>(raw: string, fallback: T): T {
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === "object" ? (parsed as T) : fallback;
} catch {
return fallback;
}
}
/** Parses a JSON column without shape validation, returning the fallback only on parse failure. */
export function parseJsonValue<T>(raw: string, fallback: T): T {
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
/** Normalizes SQLite number/bigint columns into JavaScript numbers. */
export { normalizeSqliteNumber as normalizeNumber };
@@ -43,7 +25,7 @@ export function parseJsonArray(raw: string | null): string[] | undefined {
if (!raw) {
return undefined;
}
const parsed = parseJsonObject<unknown>(raw, undefined);
const parsed = safeParseJson(raw);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: undefined;
+4 -7
View File
@@ -1,11 +1,8 @@
/** SQLite column codec for mutable cron runtime state. */
import { safeParseJson } from "@openclaw/normalization-core";
import { asRecord } from "@openclaw/normalization-core/record-coerce";
import type { CronJobState } from "../types.js";
import {
booleanToInteger,
integerToBoolean,
normalizeNumber,
parseJsonObject,
} from "./scalar-codec.js";
import { booleanToInteger, integerToBoolean, normalizeNumber } from "./scalar-codec.js";
import type { CronJobInsert, CronJobRow } from "./schema.js";
/** Maps mutable cron runtime state into normalized SQLite columns. */
@@ -49,7 +46,7 @@ export function stateFromRow(row: CronJobRow): CronJobState {
return {
// Keep unknown runtime fields from state_json while letting indexed columns
// win for fields that SQLite updates independently during hot-path writes.
...parseJsonObject<CronJobState>(row.state_json, {}),
...(asRecord(safeParseJson(row.state_json)) as CronJobState),
...(row.next_run_at_ms != null ? { nextRunAtMs: normalizeNumber(row.next_run_at_ms) } : {}),
...(row.running_at_ms != null ? { runningAtMs: normalizeNumber(row.running_at_ms) } : {}),
...(row.last_run_at_ms != null ? { lastRunAtMs: normalizeNumber(row.last_run_at_ms) } : {}),
+1 -1
View File
@@ -1,5 +1,6 @@
// Gateway handlers expose reviewed, memory-only migration plans to trusted operators.
import crypto from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import {
ErrorCodes,
errorShape,
@@ -11,7 +12,6 @@ import {
validateMigrationsMemoryPlanParams,
} from "../../../packages/gateway-protocol/src/index.js";
import { listAgentIds, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import { stableStringify } from "../../agents/stable-stringify.js";
import {
applyProviderMemoryImport,
listMemoryMigrationProviders,
@@ -2,12 +2,12 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { stableStringify } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
WorkerInferenceStartParams,
WorkerInferenceTerminalOutcome,
} from "../../../packages/gateway-protocol/src/schema/worker-inference.js";
import { stableStringify } from "../../agents/stable-stringify.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
+1 -1
View File
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import {
WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES,
type WorkerInferenceCancelParams,
@@ -14,7 +15,6 @@ import {
validateWorkerInferenceTerminalFrame,
validateWorkerInferenceTerminalOutcome,
} from "../../../packages/gateway-protocol/src/schema/worker-inference.js";
import { stableStringify } from "../../agents/stable-stringify.js";
import type { OpenClawConfig } from "../../config/types.js";
import { withTimeout } from "../../infra/fs-safe.js";
import { boundedJsonUtf8Bytes } from "../../infra/json-utf8-bytes.js";
@@ -1,11 +1,11 @@
import { createHash } from "node:crypto";
import { stableStringify } from "@openclaw/normalization-core";
import type {
WorkerTranscriptCommitParams,
WorkerTranscriptMessage,
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
import type { AgentMessage } from "../../agents/runtime/index.js";
import { SessionManager } from "../../agents/sessions/session-manager.js";
import { stableStringify } from "../../agents/stable-stringify.js";
import { redactTranscriptMessage } from "../../agents/transcript-redact.js";
import {
loadSessionEntry,
+3 -8
View File
@@ -1,8 +1,8 @@
// Respawns the gateway process when no supervisor handles restart.
import { spawn, type ChildProcess } from "node:child_process";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { scheduleDetachedLaunchdRestartHandoff } from "../daemon/launchd-restart-handoff.js";
import { isContainerEnvironment } from "./container-environment.js";
import { isTruthyEnvValue } from "./env.js";
import { formatErrorMessage } from "./errors.js";
import { triggerOpenClawRestart } from "./restart.js";
import { detectGatewayRespawnSupervisor } from "./supervisor-markers.js";
@@ -23,11 +23,6 @@ type GatewayRespawnOptions = {
env?: NodeJS.ProcessEnv;
};
function isTruthy(value: string | undefined): boolean {
const normalized = normalizeOptionalLowercaseString(value);
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
const PNPM_VERSIONED_OPENCLAW_ENTRY_PATTERN =
/^(.*?)([\\/])node_modules\2\.pnpm\2openclaw@[^\\/]+\2node_modules\2openclaw\2.+$/;
@@ -83,7 +78,7 @@ function scheduleLaunchdRestartAfterExit(): GatewayRespawnResult {
export function restartGatewayProcessWithFreshPid(
_opts: GatewayRespawnOptions = {},
): GatewayRespawnResult {
if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) {
if (isTruthyEnvValue(process.env.OPENCLAW_NO_RESPAWN)) {
return { mode: "disabled" };
}
const supervisor = detectGatewayRespawnSupervisor(process.env);
@@ -154,7 +149,7 @@ export function respawnGatewayProcessForUpdate(
}
return { mode: "supervised" };
}
if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) {
if (isTruthyEnvValue(process.env.OPENCLAW_NO_RESPAWN)) {
return { mode: "disabled", detail: "OPENCLAW_NO_RESPAWN" };
}
try {
@@ -1,5 +1,6 @@
// Verifies staged legacy transcript rows against the committed canonical store.
import type { DatabaseSync } from "node:sqlite";
import { stableStringify } from "@openclaw/normalization-core";
import type { TranscriptUtterance } from "../transcripts/provider-types.js";
import { transcriptSessionSelector, TranscriptsStore } from "../transcripts/store.js";
import {
@@ -20,23 +21,6 @@ type StoredUtteranceRow = {
utterance_id: string | null;
};
function canonicalJson(value: unknown): string {
if (value === undefined) {
return "undefined";
}
if (Array.isArray(value)) {
return `[${value.map(canonicalJson).join(",")}]`;
}
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.toSorted()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "undefined";
}
function storedUtteranceFromRow(row: StoredUtteranceRow): TranscriptUtterance {
const utterance: TranscriptUtterance = { sessionId: row.session_id, text: row.text };
if (row.utterance_id !== null) {
@@ -88,7 +72,7 @@ export async function verifyImportedMeetingTranscriptSnapshots(params: {
`);
for (const snapshot of params.snapshots) {
const session = await params.store.readSession(transcriptSessionSelector(snapshot.session));
if (!session || canonicalJson(session) !== canonicalJson(snapshot.session)) {
if (!session || stableStringify(session) !== stableStringify(snapshot.session)) {
throw new Error(`meeting transcript import verification failed: ${snapshot.relativeDir}`);
}
for (
@@ -110,14 +94,14 @@ export async function verifyImportedMeetingTranscriptSnapshots(params: {
start,
)
.map((row) => storedUtteranceFromRow(row as StoredUtteranceRow));
if (canonicalJson(actual) !== canonicalJson(expected)) {
if (stableStringify(actual) !== stableStringify(expected)) {
throw new Error(`meeting transcript import verification failed: ${snapshot.relativeDir}`);
}
}
const summary = await params.store.readSummary(session);
if (
canonicalJson(summary.summary) !== canonicalJson(snapshot.summary) ||
canonicalJson(summary.markdown?.trimEnd()) !== canonicalJson(snapshot.markdown?.trimEnd())
stableStringify(summary.summary) !== stableStringify(snapshot.summary) ||
stableStringify(summary.markdown?.trimEnd()) !== stableStringify(snapshot.markdown?.trimEnd())
) {
throw new Error(`meeting transcript summary verification failed: ${snapshot.relativeDir}`);
}
+4 -8
View File
@@ -192,12 +192,6 @@ function resolveDiagnosticLivenessRecordLevel(
return hasBlockingWork || (event.active > 0 && hasSustainedEventLoopDelay) ? "warning" : "info";
}
function isRecord(
record: DiagnosticStabilityEventRecord | undefined,
): record is DiagnosticStabilityEventRecord {
return record !== undefined;
}
function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabilityEventRecord {
const record: DiagnosticStabilityEventRecord = {
seq: event.seq,
@@ -585,12 +579,14 @@ function listRecords(): DiagnosticStabilityEventRecord[] {
return [];
}
if (state.count < state.capacity) {
return state.records.slice(0, state.count).filter(isRecord);
return state.records
.slice(0, state.count)
.filter((record): record is DiagnosticStabilityEventRecord => record !== undefined);
}
return [
...state.records.slice(state.nextIndex),
...state.records.slice(0, state.nextIndex),
].filter(isRecord);
].filter((record): record is DiagnosticStabilityEventRecord => record !== undefined);
}
function summarizeRecords(
+2 -7
View File
@@ -1,5 +1,6 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { formatErrorMessage } from "../infra/errors.js";
import { decodeMeetingAudioBase64 } from "./audio-base64.js";
import { terminateMeetingBridgeProcess } from "./bridge-process.js";
@@ -74,12 +75,6 @@ function readStringArray(value: unknown): string[] | undefined {
return result.length > 0 ? result : undefined;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
@@ -689,7 +684,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): {
throw new Error(`${options.displayName} node host received malformed params JSON.`);
}
}
const params = asRecord(raw);
const params = asOptionalRecord(raw) ?? {};
const action = readString(params.action);
let result: unknown;
switch (action) {
+2 -7
View File
@@ -1,3 +1,4 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import type {
OpenClawPluginNodeInvokePolicy,
OpenClawPluginNodeInvokePolicyResult,
@@ -29,12 +30,6 @@ type PolicyDecision =
| { approved: true; params: Record<string, unknown> }
| { approved: false; result: OpenClawPluginNodeInvokePolicyResult };
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
@@ -258,7 +253,7 @@ export function createMeetingBrowserNodeInvokePolicy(
if (ctx.command !== options.commandName) {
return denied(options, `unsupported ${options.displayName} node command: ${ctx.command}`);
}
const params = asRecord(ctx.params);
const params = asOptionalRecord(ctx.params) ?? {};
const action = readString(params.action);
if (action === "setup" && options.useConfiguredSetupCommands) {
const setupParams: Record<string, unknown> = { action };
+8 -13
View File
@@ -1,3 +1,4 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { TObject } from "typebox";
import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/schema/error-codes.js";
@@ -79,12 +80,6 @@ export type MeetingPluginEntryOptions<
unknownActionMessage: string;
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readErrorDetails(error: unknown): unknown {
return error && typeof error === "object" && "details" in error
? (error as { details?: unknown }).details
@@ -251,7 +246,7 @@ export function createMeetingPluginEntryOptions<
`${options.gatewayMethodPrefix}.join`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = keepTrustedToolContext(asRecord(params), client);
const raw = keepTrustedToolContext(asOptionalRecord(params) ?? {}, client);
respond(true, await (await ensureRuntime()).join(joinRequest(raw)));
} catch (error) {
sendRequestError(respond, error);
@@ -262,7 +257,7 @@ export function createMeetingPluginEntryOptions<
`${options.gatewayMethodPrefix}.leave`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const raw = asOptionalRecord(params) ?? {};
const agentId = trustedToolAgentId(raw, client);
const sessionId = requireString(raw.sessionId, "sessionId");
const rt = await ensureRuntime();
@@ -281,7 +276,7 @@ export function createMeetingPluginEntryOptions<
`${options.gatewayMethodPrefix}.status`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const raw = asOptionalRecord(params) ?? {};
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
respond(
@@ -299,7 +294,7 @@ export function createMeetingPluginEntryOptions<
`${options.gatewayMethodPrefix}.transcript`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const raw = asOptionalRecord(params) ?? {};
const sessionId = requireString(raw.sessionId, "sessionId");
const sinceIndex = readSinceIndex(raw);
const agentId = trustedToolAgentId(raw, client);
@@ -319,7 +314,7 @@ export function createMeetingPluginEntryOptions<
`${options.gatewayMethodPrefix}.speak`,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = asRecord(params);
const raw = asOptionalRecord(params) ?? {};
const sessionId = requireString(raw.sessionId, "sessionId");
const agentId = trustedToolAgentId(raw, client);
const rt = await ensureRuntime();
@@ -368,7 +363,7 @@ export function createMeetingPluginEntryOptions<
method,
async ({ params, client, respond }: GatewayRequestHandlerOptions) => {
try {
const raw = keepTrustedToolContext(asRecord(params), client);
const raw = keepTrustedToolContext(asOptionalRecord(params) ?? {}, client);
respond(true, await run(await ensureRuntime(), raw));
} catch (error) {
sendRequestError(respond, error);
@@ -383,7 +378,7 @@ export function createMeetingPluginEntryOptions<
description: options.toolDescription,
parameters: options.toolParameters,
async execute(_toolCallId, params) {
const raw = asRecord(params);
const raw = asOptionalRecord(params) ?? {};
const action = raw.action as MeetingToolAction;
const requesterSessionKey = normalizeOptionalString(toolContext.sessionKey);
const contextAgentId =
@@ -1,3 +1,4 @@
import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce";
import { formatErrorMessage } from "../infra/errors.js";
import type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js";
import { decodeMeetingAudioBase64 } from "./audio-base64.js";
@@ -9,12 +10,6 @@ const NODE_OUTPUT_GENERATION_CAPABILITY = Symbol.for(
"openclaw.internal.meeting-node-output-generation.v1",
);
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
@@ -85,7 +80,8 @@ export function createNodeMeetingRealtimeAudioTransport(params: {
params: { action: "pullAudio", bridgeId: params.bridgeId, timeoutMs: 250 },
timeoutMs: 2_000,
});
const result = asRecord(asRecord(raw).payload ?? raw);
const rawRecord = asOptionalRecord(raw);
const result = asOptionalRecord(rawRecord?.payload ?? raw) ?? {};
const base64 = readString(result.base64);
if (base64) {
const audio = decodeMeetingAudioBase64(base64, "pullAudio");
+1 -6
View File
@@ -5,7 +5,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { isVitestRuntimeEnv } from "../infra/env.js";
import { isTruthyEnvValue, isVitestRuntimeEnv } from "../infra/env.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { isPathInside } from "../infra/path-guards.js";
import { resolveUserPath } from "../utils.js";
@@ -39,11 +39,6 @@ function isSourceCheckoutRoot(packageRoot: string): boolean {
);
}
function isTruthyEnvValue(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
function shouldTrustTestBundledPluginsDirOverride(env: NodeJS.ProcessEnv): boolean {
const isVitestProcess = isVitestRuntimeEnv(env) || isVitestRuntimeEnv(process.env);
return (
@@ -1,4 +1,5 @@
// Checks web-search credential presence from config and plugin metadata.
import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadManifestMetadataSnapshot } from "./manifest-contract-eligibility.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
@@ -10,27 +11,24 @@ function hasConfiguredCredentialValue(value: unknown): boolean {
return value !== undefined && value !== null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function hasConfiguredSearchCredentialCandidate(searchConfig: unknown): boolean {
if (!isRecord(searchConfig)) {
const record = asOptionalObjectRecord(searchConfig);
if (!record) {
return false;
}
return Object.entries(searchConfig).some(
return Object.entries(record).some(
([key, value]) => key !== "enabled" && hasConfiguredCredentialValue(value),
);
}
function hasConfiguredPluginWebSearchCandidate(config: OpenClawConfig): boolean {
const entries = isRecord(config.plugins?.entries) ? config.plugins.entries : undefined;
const entries = asOptionalObjectRecord(config.plugins?.entries);
if (!entries) {
return false;
}
return Object.values(entries).some((entry) => {
const pluginConfig = isRecord(entry) ? entry.config : undefined;
return isRecord(pluginConfig) && hasConfiguredSearchCredentialCandidate(pluginConfig.webSearch);
const pluginConfig = asOptionalObjectRecord(entry)?.config;
return hasConfiguredSearchCredentialCandidate(asOptionalObjectRecord(pluginConfig)?.webSearch);
});
}
+1 -5
View File
@@ -6,13 +6,13 @@ import { request as httpsRequest } from "node:https";
import net from "node:net";
import { StringDecoder } from "node:string_decoder";
import { URL } from "node:url";
import { isTruthyEnvValue } from "../infra/env.js";
import { ensureDebugProxyCa } from "./ca.js";
import type { DebugProxySettings } from "./env.js";
import { redactedCaptureHeaders } from "./header-redaction.js";
import { getDebugProxyCaptureStore } from "./store.sqlite.js";
import type { CaptureEventRecord } from "./types.js";
const TRUTHY_ENV = new Set(["1", "true", "yes", "on"]);
const DEBUG_PROXY_DIRECT_CONNECT_OVERRIDE =
"OPENCLAW_DEBUG_PROXY_ALLOW_DIRECT_CONNECT_WITH_MANAGED_PROXY";
const CAPTURE_BODY_PREVIEW_BYTES = 8192;
@@ -25,10 +25,6 @@ type BodyPreviewCapture = {
truncated: boolean;
};
function isTruthyEnvValue(value: string | undefined): boolean {
return TRUTHY_ENV.has((value ?? "").trim().toLowerCase());
}
function isManagedProxyActive(env: NodeJS.ProcessEnv = process.env): boolean {
return isTruthyEnvValue(env["OPENCLAW_PROXY_ACTIVE"]);
}
+1 -1
View File
@@ -1,6 +1,6 @@
/** Process-local identity for the non-secret config that an owner may use with a credential. */
import { createHash } from "node:crypto";
import { stableStringify } from "../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { coerceSecretRef } from "../config/types.secrets.js";
import { secretRefKey } from "./ref-contract.js";
+1 -1
View File
@@ -1,6 +1,6 @@
// Session snapshot helpers capture and restore runtime skill state for sessions.
import crypto from "node:crypto";
import { stableStringify } from "../../agents/stable-stringify.js";
import { stableStringify } from "@openclaw/normalization-core";
import { redactConfigObject } from "../../config/redact-snapshot.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { matchesSkillFilter } from "../discovery/filter.js";
+18 -20
View File
@@ -6,6 +6,7 @@ import {
type OverlayHandle,
type SelectItem,
} from "@earendil-works/pi-tui";
import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce";
import { isApprovalStaleError } from "../infra/approval-errors.js";
import { formatErrorMessage } from "../infra/errors.js";
import { selectListTheme, theme } from "./theme/theme.js";
@@ -126,10 +127,6 @@ const DECISION_ITEMS: Record<TuiApprovalDecision, SelectItem> = {
},
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function parseDecision(value: unknown): TuiApprovalDecision | null {
return value === "allow-once" || value === "allow-always" || value === "deny" ? value : null;
}
@@ -154,13 +151,15 @@ function parseSeverity(value: unknown): TuiPluginApproval["request"]["severity"]
/** Parses the gateway event/list shape used for pending plugin approvals. */
function parseTuiPluginApproval(payload: unknown): TuiPluginApproval | null {
if (!isRecord(payload) || !isRecord(payload.request)) {
const record = asOptionalObjectRecord(payload);
const request = asOptionalObjectRecord(record?.request);
if (!record || !request) {
return null;
}
const id = typeof payload.id === "string" ? payload.id.trim() : "";
const title = typeof payload.request.title === "string" ? payload.request.title.trim() : "";
const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0;
const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0;
const id = typeof record.id === "string" ? record.id.trim() : "";
const title = typeof request.title === "string" ? request.title.trim() : "";
const createdAtMs = typeof record.createdAtMs === "number" ? record.createdAtMs : 0;
const expiresAtMs = typeof record.expiresAtMs === "number" ? record.expiresAtMs : 0;
if (!id || !title || !createdAtMs || !expiresAtMs) {
return null;
}
@@ -168,15 +167,13 @@ function parseTuiPluginApproval(payload: unknown): TuiPluginApproval | null {
id,
request: {
title,
description:
typeof payload.request.description === "string" ? payload.request.description : null,
pluginId: typeof payload.request.pluginId === "string" ? payload.request.pluginId : null,
severity: parseSeverity(payload.request.severity),
toolName: typeof payload.request.toolName === "string" ? payload.request.toolName : null,
allowedDecisions: parseAllowedDecisions(payload.request.allowedDecisions),
agentId: typeof payload.request.agentId === "string" ? payload.request.agentId : null,
sessionKey:
typeof payload.request.sessionKey === "string" ? payload.request.sessionKey : null,
description: typeof request.description === "string" ? request.description : null,
pluginId: typeof request.pluginId === "string" ? request.pluginId : null,
severity: parseSeverity(request.severity),
toolName: typeof request.toolName === "string" ? request.toolName : null,
allowedDecisions: parseAllowedDecisions(request.allowedDecisions),
agentId: typeof request.agentId === "string" ? request.agentId : null,
sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null,
},
createdAtMs,
expiresAtMs,
@@ -184,10 +181,11 @@ function parseTuiPluginApproval(payload: unknown): TuiPluginApproval | null {
}
function parseResolvedApprovalId(payload: unknown): string | null {
if (!isRecord(payload) || typeof payload.id !== "string") {
const id = asOptionalObjectRecord(payload)?.id;
if (typeof id !== "string") {
return null;
}
return payload.id.trim() || null;
return id.trim() || null;
}
function decisionLabel(decision: TuiApprovalDecision): string {
+20 -21
View File
@@ -6,6 +6,7 @@ import {
type OverlayHandle,
type SelectItem,
} from "@earendil-works/pi-tui";
import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce";
import type { TaskSuggestion } from "../../packages/gateway-protocol/src/index.js";
import { formatErrorMessage } from "../infra/errors.js";
import { selectListTheme, theme } from "./theme/theme.js";
@@ -64,33 +65,30 @@ function sanitizeTaskText(text: string): string {
return sanitizeRenderableText(text.replace(TASK_BIDI_CONTROL_RE, ""));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/** Parses the task suggestion shape carried by Gateway list and event payloads. */
function parseTuiTaskSuggestion(value: unknown): TaskSuggestion | null {
if (!isRecord(value)) {
const record = asOptionalObjectRecord(value);
if (!record) {
return null;
}
const required = ["id", "title", "prompt", "tldr", "cwd", "sessionKey"] as const;
if (required.some((field) => typeof value[field] !== "string" || !value[field].trim())) {
if (required.some((field) => typeof record[field] !== "string" || !record[field].trim())) {
return null;
}
if (typeof value.createdAt !== "number" || value.createdAt < 0) {
if (typeof record.createdAt !== "number" || record.createdAt < 0) {
return null;
}
return {
id: (value.id as string).trim(),
title: (value.title as string).trim(),
prompt: (value.prompt as string).trim(),
tldr: (value.tldr as string).trim(),
cwd: (value.cwd as string).trim(),
sessionKey: (value.sessionKey as string).trim(),
...(typeof value.agentId === "string" && value.agentId.trim()
? { agentId: value.agentId.trim() }
id: (record.id as string).trim(),
title: (record.title as string).trim(),
prompt: (record.prompt as string).trim(),
tldr: (record.tldr as string).trim(),
cwd: (record.cwd as string).trim(),
sessionKey: (record.sessionKey as string).trim(),
...(typeof record.agentId === "string" && record.agentId.trim()
? { agentId: record.agentId.trim() }
: {}),
createdAt: value.createdAt,
createdAt: record.createdAt,
};
}
@@ -410,11 +408,12 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
return {
handleEvent(event: string, payload: unknown) {
if (disposed || event !== "task.suggestion" || !isRecord(payload)) {
const record = asOptionalObjectRecord(payload);
if (disposed || event !== "task.suggestion" || !record) {
return;
}
if (payload.action === "created") {
const suggestion = parseTuiTaskSuggestion(payload.suggestion);
if (record.action === "created") {
const suggestion = parseTuiTaskSuggestion(record.suggestion);
if (suggestion) {
revision += 1;
hiddenIds.delete(suggestion.id);
@@ -423,8 +422,8 @@ export function createTuiTaskSuggestionController(deps: TaskSuggestionController
}
return;
}
if (payload.action === "resolved" && typeof payload.taskId === "string") {
remove(payload.taskId);
if (record.action === "resolved" && typeof record.taskId === "string") {
remove(record.taskId);
presentNext();
deps.requestRender();
}