diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 414b694c1542..7b90f916dc22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2348,6 +2348,14 @@ jobs: pnpm tool-display:check pnpm check:host-env-policy:swift pnpm dup:check:coverage + if has_package_script "check:coercion-helpers"; then + pnpm check:coercion-helpers + elif [[ "$HISTORICAL_TARGET" == "true" ]]; then + echo "[skip] historical target predates the coercion-helper declaration guard" + else + echo "Current CI targets must provide the check:coercion-helpers package script." >&2 + exit 1 + fi if [ -n "$PR_BASE_SHA" ]; then timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/ci-base" node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/ci-base --head HEAD --no-merge-base diff --git a/docs/.generated/plugin-sdk-api-baseline.jsonl b/docs/.generated/plugin-sdk-api-baseline.jsonl index 5e1e2c724a48..b8467cc986a0 100644 --- a/docs/.generated/plugin-sdk-api-baseline.jsonl +++ b/docs/.generated/plugin-sdk-api-baseline.jsonl @@ -53,17 +53,17 @@ {"closureHash":"ff3c4616cd6212a6d698831a8b287ad87e3968c8663f4090d095bb30ec40fc1c","declaration":"export function abortAndDrainAgentHarnessRun(params: { sessionId: string; sessionKey?: string; settleMs?: number; forceClear?: boolean; reason?: string; }): Promise;","entrypoint":"agent-harness","exportName":"abortAndDrainAgentHarnessRun","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"58989e3fa91eecf2e05202e36548e6e64a3b9219e83154bd99866c851604e34e","declaration":"export function createAgentToolResultMiddlewareRunner(ctx: AgentToolResultMiddlewareContext, handlers?: AgentToolResultMiddleware[]): { applyToolResultMiddleware(event: AgentToolResultMiddlewareEvent): Promise; };","entrypoint":"agent-harness","exportName":"createAgentToolResultMiddlewareRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"b8f6355d7ad1700aceecddaa6e9ccf43091a792d5b481a1ee3f55294a881a67d","declaration":"export function createCodexAppServerToolResultExtensionRunner(ctx: CodexAppServerExtensionContext, factories?: CodexAppServerExtensionFactory[]): { applyToolResultExtensions(event: CodexAppServerToolResultEvent): Promise>; };","entrypoint":"agent-harness","exportName":"createCodexAppServerToolResultExtensionRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} -{"closureHash":"1115e6754f66da077a1d2f349cf2bfd26382b0978b4efcf131d8d5e8a219e485","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} +{"closureHash":"2624f0c11a7852cedded8f42322cb6e6a6c719d6be2ec4571e8d1fb252c6eaaf","declaration":"export function createOpenClawCodingTools(options?: OpenClawCodingToolsOptions): AnyAgentTool[];","entrypoint":"agent-harness","exportName":"createOpenClawCodingTools","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"13649ee853485319e7449fda25c106b3f3f53d3090e53cdbb28e220a8529eb80","declaration":"export function disposeRegisteredAgentHarnesses(): Promise;","entrypoint":"agent-harness","exportName":"disposeRegisteredAgentHarnesses","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"f255a91162ece4c239bb8fa745efa14a1054bc024a291e9954f75f239b2b87c3","declaration":"export function resolveActiveEmbeddedRunSessionId(sessionKey: string): string | undefined;","entrypoint":"agent-harness","exportName":"resolveActiveEmbeddedRunSessionId","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} {"closureHash":"27d9f61df9ccf68615cb2002a6da891e36ab0c07623bc37c4d07aa980b446881","declaration":"export function resolveWebSearchToolPolicy(params: WebSearchToolPolicyParams): WebSearchToolPolicyResolution;","entrypoint":"agent-harness","exportName":"resolveWebSearchToolPolicy","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"function","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} -{"closureHash":"65b516857f90d0029866f6b3179cac436dd6d5c85bd83d440fbcdbe297b70bf6","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"ca20bb488d357785086f04e71b0fbb300a27325217bdbfdcaa1e422eca2fe255","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"4ad0f830bf11a2db48faefb1439b7fbb08d9f22d22083f5e301327e1696f2310","declaration":"export type AgentToolResultMiddleware = AgentToolResultMiddleware;","entrypoint":"agent-harness","exportName":"AgentToolResultMiddleware","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"008b6c78d5e44a9d1df1d7e32e6015382ee9ba1cb9b10bd4c2b467619d1c89d4","declaration":"export type AgentToolResultMiddlewareEvent = AgentToolResultMiddlewareEvent;","entrypoint":"agent-harness","exportName":"AgentToolResultMiddlewareEvent","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"7414a2a79c2d78c1d52538250c827305bba1a2bb25010448837c8524a9f95b3c","declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"agent-harness","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} -{"closureHash":"f6d52c26f4fa2377f384935aa8e0c990e8634bc56ddc35dc00f4e00d5dfd16c4","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} -{"closureHash":"b96b39b906aac64a2ce1037ad8f6f811f832b5189f53c0c966cba9dedca50615","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"b0d89fe509b6812c24b0a46cbc7d7ce20af0009a415c4e50e20c41af02975b11","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} +{"closureHash":"67dd9a76536fb9f41ae19a380dd71bf10682ff80c63f363b5f8511948dcd1aab","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"closureHash":"25c6985c3bef2c6efc6a96e70af6d66fe073be0b0d311681cfc0dfc186528d9a","declaration":"export type OpenClawAgentToolResult = OpenClawAgentToolResult;","entrypoint":"agent-harness","exportName":"OpenClawAgentToolResult","importSpecifier":"openclaw/plugin-sdk/agent-harness","kind":"type","recordType":"export"} {"category":"runtime","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","recordType":"module"} {"closureHash":"fab99dfcb01d475a1bfdbe7a8fac9be10a93dac5f12ac78c2dea673c8ab86abf","declaration":"export function abortAgentHarnessRun(sessionId: string): boolean;\nexport function abortAgentHarnessRun(sessionId: undefined, opts: { mode: \"all\" | \"compacting\"; reason?: \"restart\"; }): boolean;","entrypoint":"agent-harness-runtime","exportName":"abortAgentHarnessRun","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -82,8 +82,8 @@ {"closureHash":"f8ad3d762c9753fdc36b2b9af0980167fdc54090c7bcca2f3dbe7c8951e86821","declaration":"export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): AgentRuntimePlan;","entrypoint":"agent-harness-runtime","exportName":"buildAgentRuntimePlan","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"528d11abf061b0b303722737de5a6406e7e55bffa38c45144c77d896ae1b3b0a","declaration":"export function buildBootstrapContextForFiles(bootstrapFiles: WorkspaceBootstrapFile[], params: { config?: OpenClawConfig; agentId?: string | null; warn?: (message: string) => void; }): EmbeddedContextFile[];","entrypoint":"agent-harness-runtime","exportName":"buildBootstrapContextForFiles","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"44d5a4667d2f3dc3a28e5c9d7b46765b1fb3c62820288e40251f5174911c8a6a","declaration":"export function buildEmbeddedAttemptToolRunContext(params: { trigger?: EmbeddedRunTrigger; jobId?: string; memoryFlushWritePath?: string; toolsAllow?: string[]; conversationToolPolicy?: GroupToolPolicyConfig; trace?: DiagnosticTraceContext; }): { trigger?: EmbeddedRunTrigger; jobId?: string; memoryFlushWritePath?: string; runtimeToolAllowlist?: string[]; conversationToolPolicy?: GroupToolPolicyConfig; trace?: DiagnosticTraceContext; };","entrypoint":"agent-harness-runtime","exportName":"buildEmbeddedAttemptToolRunContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"faa83a109c85aaabd294ba35f76000b22901b5d6cc33a57f9cfbb7e3792c6506","declaration":"export function buildHarnessContextEngineRuntimeContext(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"e10f6ebee88eb98a88ea66a8d1dfff03f7ce1b82c50dc3b5d130652b450e9ccc","declaration":"export function buildHarnessContextEngineRuntimeContextFromUsage(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContextFromUsage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"e194f5cdcc6a602b877027a0c105c9e850c02f81439eb655d3e9d56febc28a22","declaration":"export function buildHarnessContextEngineRuntimeContext(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"74b53fe40a520f5dffa1593ed0bfea15ac8b08cc74428d03375e47e9bed0adc1","declaration":"export function buildHarnessContextEngineRuntimeContextFromUsage(params: Parameters[0]): ContextEngineRuntimeContext;","entrypoint":"agent-harness-runtime","exportName":"buildHarnessContextEngineRuntimeContextFromUsage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"33a7cd8d63d4ef045ee3a42f806026a8b621e7dd32d8d6fbf4e7b44452d5e0ce","declaration":"export function buildNativeHookRelayCommand(params: { provider: NativeHookRelayProvider; relayId: string; generation?: string; event: NativeHookRelayEvent; preToolUseUnavailable?: \"noop\"; timeoutMs?: number; executable?: string; nice?: number | false; nodeExecutable?: string; }): string;","entrypoint":"agent-harness-runtime","exportName":"buildNativeHookRelayCommand","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"3846647c3c2fb0242f70591cf95c2e02f2a5a3c44447efb4cf7a31b2dc2122fd","declaration":"export function buildSkillWorkshopPromptSection(): string[];","entrypoint":"agent-harness-runtime","exportName":"buildSkillWorkshopPromptSection","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"32e977ece65144a83ae1b0a3c8d1b9b00a2a2506ad9ef1db3b2b9770ce762dbe","declaration":"export function buildWatchedSessionsHarnessContext(params: { config?: OpenClawConfig; sessionKey?: string; sandboxed?: boolean; toolNames: Iterable; capabilityToolNames?: Iterable; }): string | undefined;","entrypoint":"agent-harness-runtime","exportName":"buildWatchedSessionsHarnessContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -100,7 +100,7 @@ {"closureHash":"51f76e9e37b65b358f0313e10152d950b003bc3e50f4bb26fe29a988237772e2","declaration":"export function consumePreExecutionBlockedToolCall(toolCallId: string, runId?: string): boolean;","entrypoint":"agent-harness-runtime","exportName":"consumePreExecutionBlockedToolCall","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"58989e3fa91eecf2e05202e36548e6e64a3b9219e83154bd99866c851604e34e","declaration":"export function createAgentToolResultMiddlewareRunner(ctx: AgentToolResultMiddlewareContext, handlers?: AgentToolResultMiddleware[]): { applyToolResultMiddleware(event: AgentToolResultMiddlewareEvent): Promise; };","entrypoint":"agent-harness-runtime","exportName":"createAgentToolResultMiddlewareRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"b8f6355d7ad1700aceecddaa6e9ccf43091a792d5b481a1ee3f55294a881a67d","declaration":"export function createCodexAppServerToolResultExtensionRunner(ctx: CodexAppServerExtensionContext, factories?: CodexAppServerExtensionFactory[]): { applyToolResultExtensions(event: CodexAppServerToolResultEvent): Promise>; };","entrypoint":"agent-harness-runtime","exportName":"createCodexAppServerToolResultExtensionRunner","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"b7399f160a14a25556f800de6cede4d57349dd45aa90f59a8fae5aaf19c01913","declaration":"export function deliverAgentHarnessUserInputPrompt(params: PromptDeliveryParams, questions: readonly AgentHarnessUserInputQuestion[], options?: AgentHarnessUserInputPromptOptions): Promise;","entrypoint":"agent-harness-runtime","exportName":"deliverAgentHarnessUserInputPrompt","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"b63d7e807de37989954018686a2c3ddaa30f4bb71d610221b178495f978ef9bb","declaration":"export function deliverAgentHarnessUserInputPrompt(params: PromptDeliveryParams, questions: readonly AgentHarnessUserInputQuestion[], options?: AgentHarnessUserInputPromptOptions): Promise;","entrypoint":"agent-harness-runtime","exportName":"deliverAgentHarnessUserInputPrompt","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"6f436485257f69fa07a18203b91d4341690522f12aff4f25805c7a1e21326ee3","declaration":"export function detectAndLoadAgentHarnessPromptImages(params: { prompt: string; workspaceDir: string; model: { input?: string[]; }; existingImages?: ImageContent[]; imageOrder?: PromptImageOrderEntry[]; media?: MediaFact[]; config?: OpenClawConfig; workspaceOnly?: boolean; localRoots?: readonly string[]; sandbox?: { root: string; bridge: SandboxFsBridge; }; }): Promise<{ images: ImageContent[]; detectedRefs: Array<{ raw: string; resolved: string; type: \"path\" | \"media-uri\"; }>; loadedCount: number; skippedCount: number; }>;","entrypoint":"agent-harness-runtime","exportName":"detectAndLoadAgentHarnessPromptImages","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"13649ee853485319e7449fda25c106b3f3f53d3090e53cdbb28e220a8529eb80","declaration":"export function disposeRegisteredAgentHarnesses(): Promise;","entrypoint":"agent-harness-runtime","exportName":"disposeRegisteredAgentHarnesses","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"c38cd390190bb07ddcd247420d6e0b489a0229bd402ddca0440417217a57251a","declaration":"export function emitAgentEvent(event: Omit): void;","entrypoint":"agent-harness-runtime","exportName":"emitAgentEvent","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -162,7 +162,7 @@ {"closureHash":"40154c3b7b2a7179df33f4a3fbef5e56078be2759d5aab29f39eb0f3c92fc631","declaration":"export function projectRuntimeToolInputSchema(schema: unknown, path?: string): RuntimeToolInputSchemaProjection;","entrypoint":"agent-harness-runtime","exportName":"projectRuntimeToolInputSchema","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"e42e3dc63e8545bb9a6056336fbd6c1dc8f356dce4f0f1c6d9765254c974390e","declaration":"export function projectSettledTurnFinalizationAttemptResult(result: AgentHarnessAttemptResult): AgentHarnessSettledTurnFinalizationResult;","entrypoint":"agent-harness-runtime","exportName":"projectSettledTurnFinalizationAttemptResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"3fbe470f065d2c2f16de538d8347934ec2daee2081d8903db1777b0afd2a8043","declaration":"export function queueAgentHarnessMessage(sessionId: string, text: string, options?: EmbeddedAgentQueueMessageOptions): boolean;","entrypoint":"agent-harness-runtime","exportName":"queueAgentHarnessMessage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"acb8a5ff66840716e1996e6e1ffa633baa50cf4fd79a1eb203f45c09f62ee2ac","declaration":"export function registerNativeHookRelay(params: RegisterNativeHookRelayParams): ActiveNativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"registerNativeHookRelay","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"ed44ea59be68bee94ef22b61cd88171c361275dcc72e786f13155e997c803b28","declaration":"export function registerNativeHookRelay(params: RegisterNativeHookRelayParams): ActiveNativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"registerNativeHookRelay","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"b7cc6c35506dc6955e0359aa5c53c15a4d0db23639da8c1717f8fefda953c119","declaration":"export function requestDeferredPluginToolApproval(params: { deferredApproval: DeferredPluginToolApproval; signal?: AbortSignal; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"requestDeferredPluginToolApproval","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"a2306cba08cb05ff8430c8362c0cc3c821706da5a4e65a06849ad0d0fb007ae3","declaration":"export function resetAgentEventsForTest(options?: { preserveListeners?: boolean; }): void;","entrypoint":"agent-harness-runtime","exportName":"resetAgentEventsForTest","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"f255a91162ece4c239bb8fa745efa14a1054bc024a291e9954f75f239b2b87c3","declaration":"export function resolveActiveEmbeddedRunSessionId(sessionKey: string): string | undefined;","entrypoint":"agent-harness-runtime","exportName":"resolveActiveEmbeddedRunSessionId","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -197,7 +197,7 @@ {"closureHash":"e4784aeda4c24b60cd49b7f5983e70a380a4759d5176c74d30f2900952ba848f","declaration":"export function runAgentHarnessBeforeAgentFinalizeHook(params: { event: PluginHookBeforeAgentFinalizeEvent; ctx: AgentHarnessHookContext; hookRunner?: AgentHarnessHookRunner; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessBeforeAgentFinalizeHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"8fec73ee8c7c9be3335bd6dc1bac9278f0be5beef22a5975eb06e56f504f4bd6","declaration":"export function runAgentHarnessBeforeCompactionHook(params: { sessionFile: string; messages?: AgentMessage[]; ctx: AgentHarnessHookContext; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessBeforeCompactionHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"021b81820bef2e8d54c50a5fc159d6665b23b50a1f5c40dae76a4833839d89c9","declaration":"export function runAgentHarnessBeforeMessageWriteHook(params: { message: AgentMessage; agentId?: string; sessionKey?: string; }): AgentMessage | null;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessBeforeMessageWriteHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} -{"closureHash":"e0214147021b247e720c4a4ae1fc8597c082c2c4286ac131b6f981f0895b8b0f","declaration":"export function runAgentHarnessGatewayQuestion(params: RunAgentHarnessGatewayQuestionParams): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessGatewayQuestion","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} +{"closureHash":"d40db54ef0fcc481063d718dea2ed1bdbe64b75aee82cd0efcf02ba86039765d","declaration":"export function runAgentHarnessGatewayQuestion(params: RunAgentHarnessGatewayQuestionParams): Promise;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessGatewayQuestion","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"50a4d7cb20dd8f884abbcb62d77b5b2df183fbfcd058cc7aa76edacd1f7b90d8","declaration":"export function runAgentHarnessLlmInputHook(params: { event: PluginHookLlmInputEvent; ctx: AgentHarnessHookContext; hookRunner?: AgentHarnessHookRunner; }): void;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessLlmInputHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"1bd55c141c249b1c04470cafe0cd4d41dce334ea2883ce83235af56d24a15cca","declaration":"export function runAgentHarnessLlmOutputHook(params: { event: PluginHookLlmOutputEvent; ctx: AgentHarnessHookContext; hookRunner?: AgentHarnessHookRunner; }): void;","entrypoint":"agent-harness-runtime","exportName":"runAgentHarnessLlmOutputHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} {"closureHash":"c4c5131ab7065decd8d8e935ac3779e3baa2c52fbbbd95a6ccb624377db09ca1","declaration":"export function runBeforeToolCallHook(args: { toolName: string; params: unknown; toolKind?: PluginHookToolKind; toolInputKind?: PluginHookToolInputKind; toolCallId?: string; ctx?: HookContext; signal?: AbortSignal; approvalMode?: \"request\" | \"report\" | \"deny\" | \"defer\"; }): Promise;","entrypoint":"agent-harness-runtime","exportName":"runBeforeToolCallHook","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"function","recordType":"export"} @@ -219,13 +219,13 @@ {"closureHash":"0701a22057edc3b733da7e05948db94aa98b337b224a5718efdd85212a8602bf","declaration":"export const TRANSCRIPT_CREDENTIAL_SAFETY_PROMPT: string;","entrypoint":"agent-harness-runtime","exportName":"TRANSCRIPT_CREDENTIAL_SAFETY_PROMPT","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} {"closureHash":"b5845c99df6c2a834a16678c320e0111bad96a3a7ac245d379aa5e6d2555d29b","declaration":"export const agentHarnessAttemptTerminal: { merge: (current: AgentRunAttemptTerminal, incoming: AgentRunAttemptTerminal) => AgentRunAttemptTerminal; normalize: (input: { aborted?: boolean; externalAbort?: boolean; idleTimedOut?: boolean; promptError?: unknown; promptErrorSource?: AgentRunAttemptFailureSource | null; timedOut?: boolean; timedOutByRunBudget?: boolean; timedOutDuringCompaction?: boolean; timedOutDuringToolExecution?: boolean; }) => AgentRunAttemptTerminal; project: (terminal: AgentRunAttemptTerminal) => { aborted: boolean; cleanupYieldAborted: boolean; externalAbort: boolean; failed: boolean; idleTimedOut: boolean; interrupted: boolean; promptError: unknown; promptErrorSource: AgentRunAttemptFailureSource | null; timedOut: boolean; timedOutByRunBudget: boolean; timedOutDuringCompaction: boolean; timedOutDuringToolExecution: boolean; }; setFailure: (terminal: AgentRunAttemptTerminal, failure: { source: AgentRunAttemptFailureSource; error: unknown; } | null) => AgentRunAttemptTerminal;};","entrypoint":"agent-harness-runtime","exportName":"agentHarnessAttemptTerminal","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} {"closureHash":"22bcf962aa76c76074c4ed4ad7d6558edbc3588e239263e09af74c1000bbe012","declaration":"export const embeddedAgentLog: SubsystemLogger;","entrypoint":"agent-harness-runtime","exportName":"embeddedAgentLog","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} -{"closureHash":"6e65a3ec38a9eef02bee0ae22b71a7008ffe3253d15f7cf9f5c0a9d2bf1729e0","declaration":"export const nativeHookRelayTesting: { readonly clearNativeHookRelaysForTests: () => void; readonly getNativeHookRelayInvocationsForTests: () => NativeHookRelayInvocation[]; readonly getNativeHookRelayRegistrationForTests: (relayId: string) => NativeHookRelayRegistration | undefined; readonly getNativeHookRelayBridgeDirForTests: () => string; readonly getNativeHookRelayBridgeRegistryPathForTests: (relayId: string) => string; readonly getNativeHookRelayBridgeRecordForTests: (relayId: string) => Record | undefined; readonly isNativeHookRelayBridgeLookupRetryableForTests: (error: unknown, elapsedMs?: number) => boolean; readonly formatPermissionApprovalDescriptionForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestContentFingerprintForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestToolInputKeyFingerprintForTests: (toolInput: Record) => string; readonly setNativeHookRelayPermissionApprovalRequesterForTests: (requester: NativeHookRelayPermissionApprovalRequester) => void; readonly setNativeHookRelayDeferredToolApprovalRequesterForTests: (requester: NativeHookRelayDeferredToolApprovalRequester) => void;};","entrypoint":"agent-harness-runtime","exportName":"nativeHookRelayTesting","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} +{"closureHash":"3bf4f5b98fbf675ac0208efd9de33e62abb30a5a8063e99a661f097d0dee7b84","declaration":"export const nativeHookRelayTesting: { readonly clearNativeHookRelaysForTests: () => void; readonly getNativeHookRelayInvocationsForTests: () => NativeHookRelayInvocation[]; readonly getNativeHookRelayRegistrationForTests: (relayId: string) => NativeHookRelayRegistration | undefined; readonly getNativeHookRelayBridgeDirForTests: () => string; readonly getNativeHookRelayBridgeRegistryPathForTests: (relayId: string) => string; readonly getNativeHookRelayBridgeRecordForTests: (relayId: string) => Record | undefined; readonly isNativeHookRelayBridgeLookupRetryableForTests: (error: unknown, elapsedMs?: number) => boolean; readonly formatPermissionApprovalDescriptionForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestContentFingerprintForTests: (request: NativeHookRelayPermissionApprovalRequest) => string; readonly permissionRequestToolInputKeyFingerprintForTests: (toolInput: Record) => string; readonly setNativeHookRelayPermissionApprovalRequesterForTests: (requester: NativeHookRelayPermissionApprovalRequester) => void; readonly setNativeHookRelayDeferredToolApprovalRequesterForTests: (requester: NativeHookRelayDeferredToolApprovalRequester) => void;};","entrypoint":"agent-harness-runtime","exportName":"nativeHookRelayTesting","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"const","recordType":"export"} {"closureHash":"f237bbc11e1d389679cd4e2ad7aa9055951b3029348e3374b81f769786a51d55","declaration":"export type AbortAndDrainAgentHarnessRunResult = AbortAndDrainEmbeddedAgentRunResult;","entrypoint":"agent-harness-runtime","exportName":"AbortAndDrainAgentHarnessRunResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6f802f46717d28d260bbcb2cf4790dfed4e9ab28c4fe822d259228189cca4665","declaration":"export type AgentApprovalEventData = AgentApprovalEventData;","entrypoint":"agent-harness-runtime","exportName":"AgentApprovalEventData","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"12aac3a57ce200b0f082002be237625729f84ed7c337f1bac90bcd0688a53319","declaration":"export type AgentEventPayload = AgentEventPayload;","entrypoint":"agent-harness-runtime","exportName":"AgentEventPayload","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness-runtime","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarnessAttemptParams = AgentHarnessAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"4759104bcc383686c88c8abe83e69cf8ea009becfc6321e2924f447eab73ea04","declaration":"export type AgentHarnessAttemptParamsV2 = AgentHarnessAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"agent-harness-runtime","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarnessAttemptParams = AgentHarnessAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"f6c32237f90d7fe6a024b516a1dd2e6e395c42c66db291649dab441fc1a84ebc","declaration":"export type AgentHarnessAttemptParamsV2 = AgentHarnessAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"2dc2a62deea63b316142cc28d40afb88fb0d1e5ce3e048f141420726cda38248","declaration":"export type AgentHarnessAttemptResult = AgentHarnessAttemptResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAttemptResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"c7e642234081160830fd9e18a301900f8ee1ea8c88382e5a286f0102de65b714","declaration":"export type AgentHarnessAuthBindingFingerprintParams = AgentHarnessAuthBindingFingerprintParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessAuthBindingFingerprintParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"8f496ce75f294f2aa440a49c3c5b065ddff3a4714df775116af5e53f7fd14fe5","declaration":"export type AgentHarnessCompactParams = CompactEmbeddedAgentSessionParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessCompactParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -239,8 +239,8 @@ {"closureHash":"95741b872a194687696eba4a96d6e887ec6e81922695fa0282cb2f0a46ff9f97","declaration":"export type AgentHarnessSessionForkParams = AgentHarnessSessionForkParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSessionForkParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"f3c46fef2a6c3393b0b6ad09915abcd6f57bb58ddddde1d60ebd113c7a1c5fa6","declaration":"export type AgentHarnessSessionForkResult = AgentHarnessSessionForkResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSessionForkResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"4b5a59730309e446f59a8ec1c554c3193aca1959916594cc657347098bd67a35","declaration":"export type AgentHarnessSettledTurnFinalizationResult = AgentHarnessSettledTurnFinalizationResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSettledTurnFinalizationResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"2b1360e9069d9dd3d4f620b5dd962312128d71e0ce2c91d28b521dc97a8f5785","declaration":"export type AgentHarnessSideQuestionParams = AgentHarnessSideQuestionParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"468d17121be5df45a4f9af2a72586e03deccdee7c56c43577bbd209541498ab7","declaration":"export type AgentHarnessSideQuestionParamsV2 = AgentHarnessSideQuestionParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"cf635da6cc7587a76acf23e9b488e32f10eac93aa77df664c79f8e9d9e7117be","declaration":"export type AgentHarnessSideQuestionParams = AgentHarnessSideQuestionParams;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"9cccf03a0d94f0917acf7759c8438a447288490f2fa616890e342faa501f84ac","declaration":"export type AgentHarnessSideQuestionParamsV2 = AgentHarnessSideQuestionParamsV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"e84173055503c40dd0ef1cb67c6d333e9257fcf995252b27c1d58f5c904e8de9","declaration":"export type AgentHarnessSideQuestionResult = AgentHarnessSideQuestionResult;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSideQuestionResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"50214d08dac98e3c1031ac7bc733b85cb2247d2eef3b6b2045758ae403af7647","declaration":"export type AgentHarnessSupport = AgentHarnessSupport;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSupport","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"50ce26ea22bc225e6064d2e5f37356b6f4ce4a57f8b9fa42baa4c421da48aad5","declaration":"export type AgentHarnessSupportContext = AgentHarnessSupportContext;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessSupportContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -250,7 +250,7 @@ {"closureHash":"20a8a7c9d5ebf425c30d04eea3856c6c889a210f1fa53c99299df24882afbf92","declaration":"export type AgentHarnessUserInputOption = AgentHarnessUserInputOption;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessUserInputOption","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6224f51b4189f10a642ad7a8f50a902ec9c6a6a4f8dac0c0de5f7bc0271a27e5","declaration":"export type AgentHarnessUserInputPromptOptions = AgentHarnessUserInputPromptOptions;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessUserInputPromptOptions","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"d8f1d0d118e1536d1f442d11aeae5334a1ed7949a6c61824f547e12931278e1d","declaration":"export type AgentHarnessUserInputQuestion = AgentHarnessUserInputQuestion;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessUserInputQuestion","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"65b516857f90d0029866f6b3179cac436dd6d5c85bd83d440fbcdbe297b70bf6","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"ca20bb488d357785086f04e71b0fbb300a27325217bdbfdcaa1e422eca2fe255","declaration":"export type AgentHarnessV2 = AgentHarnessV2;","entrypoint":"agent-harness-runtime","exportName":"AgentHarnessV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6f2af683e6a31cde5a473c77e4f03b8f0ec0351a43ea6c0af979d5b394a33f95","declaration":"export type AgentMessage = AgentMessage;","entrypoint":"agent-harness-runtime","exportName":"AgentMessage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"4ad0f830bf11a2db48faefb1439b7fbb08d9f22d22083f5e301327e1696f2310","declaration":"export type AgentToolResultMiddleware = AgentToolResultMiddleware;","entrypoint":"agent-harness-runtime","exportName":"AgentToolResultMiddleware","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"185abd2870d4df65e9f94623061a30167d17ef1279a395201fe429398bf23500","declaration":"export type AgentToolResultMiddlewareContext = AgentToolResultMiddlewareContext;","entrypoint":"agent-harness-runtime","exportName":"AgentToolResultMiddlewareContext","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -276,8 +276,8 @@ {"closureHash":"f30b66fd82f4cd61c60880c42a3b8f152ca64a2216036c99ab4129648ed329e7","declaration":"export type EmbeddedAgentCompactResult = EmbeddedAgentCompactResult;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedAgentCompactResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"1bbefad0193436f7340f9d0dec50be5d482973325c3c65170b1053dd05566f0a","declaration":"export type EmbeddedContextFile = EmbeddedContextFile;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedContextFile","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"f30b66fd82f4cd61c60880c42a3b8f152ca64a2216036c99ab4129648ed329e7","declaration":"export type EmbeddedPiCompactResult = EmbeddedAgentCompactResult;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedPiCompactResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"f6d52c26f4fa2377f384935aa8e0c990e8634bc56ddc35dc00f4e00d5dfd16c4","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"b96b39b906aac64a2ce1037ad8f6f811f832b5189f53c0c966cba9dedca50615","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"b0d89fe509b6812c24b0a46cbc7d7ce20af0009a415c4e50e20c41af02975b11","declaration":"export type EmbeddedRunAttemptParams = EmbeddedRunAttemptParams;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParams","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"67dd9a76536fb9f41ae19a380dd71bf10682ff80c63f363b5f8511948dcd1aab","declaration":"export type EmbeddedRunAttemptParamsV2 = EmbeddedRunAttemptParamsV2;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptParamsV2","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"a6cd2eebe0d06e6578ec8438a207c5acda10ddf01041102d84e4c576a0b6a42f","declaration":"export type EmbeddedRunAttemptResult = EmbeddedRunAttemptResult;","entrypoint":"agent-harness-runtime","exportName":"EmbeddedRunAttemptResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"f6cb2d6411db22cb04220777720d97bcca977351de1cb344ce2ff6ab126a3a08","declaration":"export type ExecApprovalDecision = ExecApprovalDecision;","entrypoint":"agent-harness-runtime","exportName":"ExecApprovalDecision","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"7acb0dffc7bcda6c727e4afc49c955fa8e14f4f8b7b45d197ed794dfa0d73b8e","declaration":"export type ExecAutoReviewDecision = ExecAutoReviewDecision;","entrypoint":"agent-harness-runtime","exportName":"ExecAutoReviewDecision","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -293,7 +293,7 @@ {"closureHash":"d8a720bf250da8daf4dc6a52087aa8728ec33b650b3ad3906fcb40e184de66a0","declaration":"export type NativeHookRelayEvent = \"pre_tool_use\" | \"post_tool_use\" | \"permission_request\" | \"before_agent_finalize\";","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayEvent","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"cb72ca18e056e52e779d0bf5b67606e1dc160f225225b55937304541105027f4","declaration":"export type NativeHookRelayProcessResponse = NativeHookRelayProcessResponse;","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayProcessResponse","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"6ee390ae4784b987e22255c16884fb4171033bbfaefaef655e3dd2a2513814a5","declaration":"export type NativeHookRelayProvider = \"codex\";","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayProvider","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} -{"closureHash":"41d1cdaabc26115ac40b9eed172da5f18884e89ac8e198e1b63f48a685f66d62","declaration":"export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayRegistrationHandle","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} +{"closureHash":"b8476f913ffe1828ec5a260a90635d3766d93e772f6a34f804e45ba287c863c5","declaration":"export type NativeHookRelayRegistrationHandle = NativeHookRelayRegistrationHandle;","entrypoint":"agent-harness-runtime","exportName":"NativeHookRelayRegistrationHandle","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"90fea3155b5d32c22da90460fdaea94e4fd6895d1ca18613d5036ba9835153f9","declaration":"export type NodeListNode = NodeListNode;","entrypoint":"agent-harness-runtime","exportName":"NodeListNode","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"cefe1325c866f0cf7edbec5dcca2fe420237eedc0da172e54094e365894d205d","declaration":"export type NormalizedUsage = NormalizedUsage;","entrypoint":"agent-harness-runtime","exportName":"NormalizedUsage","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} {"closureHash":"25c6985c3bef2c6efc6a96e70af6d66fe073be0b0d311681cfc0dfc186528d9a","declaration":"export type OpenClawAgentToolResult = OpenClawAgentToolResult;","entrypoint":"agent-harness-runtime","exportName":"OpenClawAgentToolResult","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime","kind":"type","recordType":"export"} @@ -762,7 +762,7 @@ {"closureHash":"9fc7e45343d7bfbc6ed56d05c9f824e2f9cf6dafc3ca4b25437d1f65c2200727","declaration":"export function clearAccountEntryFields(params: { accounts?: Record; accountId: string; fields: string[]; isValueSet?: (value: unknown) => boolean; markClearedOnFieldPresence?: boolean; }): { nextAccounts?: Record; changed: boolean; cleared: boolean; };","entrypoint":"channel-core","exportName":"clearAccountEntryFields","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"f4e084b64b5376268885a73fe507aa5d72bee17a88ec2d7ae1d19b36ea5f1ccb","declaration":"export function createChannelConfigUiHints(params: { channelLabel: string; dmPolicy?: { channelKey: string; includeLegacyNestedPolicy?: boolean; legacyNestedPolicyOrder?: \"before\" | \"after\"; }; configWrites?: boolean; mentionPatterns?: { targetDescription: string; policyTargetDescription?: string; policyNote?: string; denyNote?: string; }; nativeCommands?: boolean; implicitMentions?: boolean; progress?: { includeCommentary?: boolean; commentaryOrder?: \"before-command\" | \"after-command\"; labels?: \"openclaw\"; titleWording?: boolean; }; streaming?: Partial>; retry?: boolean; }): HintMap;","entrypoint":"channel-core","exportName":"createChannelConfigUiHints","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"ed8c322fac76968f7f059b65eaa881098ed918b22705b27101166b052213a9d9","declaration":"export function createChatChannelPlugin(params: { base: ChatChannelPluginBase; security?: ChannelSecurityAdapter | ChatChannelSecurityOptions; pairing?: ChannelPairingAdapter | ChatChannelPairingOptions; threading?: ChannelThreadingAdapter | ChatChannelThreadingOptions; outbound?: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions; }): ChannelPlugin;","entrypoint":"channel-core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} -{"closureHash":"20eebe7796477e813273c70382bb74de3f830573f2a84500e969305ad6f7d251","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"channel-core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} +{"closureHash":"19ee364314ffd766b8f15835014206753bc1f96af4d7084b97a0a1cc2774d4df","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"channel-core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"0e0a9f6b22c433bdb1165e7707b1f9a1ddac1a544a8add00b503048cf157921f","declaration":"export function defineSetupPluginEntry(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"channel-core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"8895f7af11b994dcd383ebc45d8cfb3c3b1e6d88a64a001b04f604a83f77e517","declaration":"export function parseOptionalDelimitedEntries(value?: string): string[] | undefined;","entrypoint":"channel-core","exportName":"parseOptionalDelimitedEntries","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} {"closureHash":"3f97534b10d08eda6a556044eaba623b3744b200a2033877e48d2f2ca924d298","declaration":"export function recoverCurrentThreadSessionId(params: { route: ChannelOutboundSessionRoute; currentSessionKey?: string | null; canRecover?: (context: ThreadAwareOutboundSessionRouteRecoveryContext) => boolean; }): string | undefined;","entrypoint":"channel-core","exportName":"recoverCurrentThreadSessionId","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"function","recordType":"export"} @@ -774,24 +774,24 @@ {"closureHash":"8126b651a679d5435eaa5d346916792c3600e5f0f75d8492b077dd361854f7dd","declaration":"export type ChannelOutboundSessionRouteParams = { cfg: OpenClawConfig; agentId: string; accountId?: string | null; target: string; currentSessionKey?: string; resolvedTarget?: { to: string; kind: ChannelDirectoryEntryKind | \"channel\"; display?: string; source: \"normalized\" | \"directory\"; }; replyToId?: string | null; threadId?: string | number | null;};","entrypoint":"channel-core","exportName":"ChannelOutboundSessionRouteParams","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"37b373e93fe7f86fdbce119947c9e49b17e71ce50fe47e118c4d078c5cea20ec","declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"channel-core","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"channel-core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"86553d1dbbbe0b04d6a233ab33dee847c2cf5f9809032d41c5bff6bec06b78da","declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"channel-core","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"closureHash":"4180509522e17f8afdf5014bc51f5601952dfa403503e66f8f53e92b0c6ddd45","declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"channel-core","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/channel-core","kind":"type","recordType":"export"} {"category":"channel","entrypoint":"channel-dm-policy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy","recordType":"module"} {"closureHash":"821fe6ccd0d9323322bf70c4d1ca5697071a0e32032f44c1e14157162c2c729d","declaration":"export function createChannelDmPolicy(params: CreateChannelDmPolicyParams): ChannelSetupDmPolicy & { promptAllowFrom: NonNullable; };","entrypoint":"channel-dm-policy","exportName":"createChannelDmPolicy","importSpecifier":"openclaw/plugin-sdk/channel-dm-policy","kind":"function","recordType":"export"} {"category":null,"entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","recordType":"module"} -{"closureHash":"69a48a0339b8298cbf357f4b03fd5d0bdd64dd0900b1c9aa0eec9ea1d967f9ed","declaration":"export function defineBundledChannelEntry({ id, name, description, importMetaUrl, plugin, outbound, secrets, configSchema, runtime, accountInspect, features, registerCliMetadata, registerFull, registerCapabilities, }: DefineBundledChannelEntryOptions): BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} -{"closureHash":"a9d4fe591235caf5ebf66ed031d29a7b23bfe4b83fe3506ee97f61f02ce31165","declaration":"export function defineBundledChannelSetupEntry({ importMetaUrl, plugin, secrets, runtime, legacyStateMigrations, legacySessionSurface, registerSetupRuntime, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelSetupEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} +{"closureHash":"449cfd12281e321f062ab0decf424e0dcdc9c4542aaed374c1d513c2e520a0fb","declaration":"export function defineBundledChannelEntry({ id, name, description, importMetaUrl, plugin, outbound, secrets, configSchema, runtime, accountInspect, features, registerCliMetadata, registerFull, registerCapabilities, }: DefineBundledChannelEntryOptions): BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} +{"closureHash":"73376c2d9f2518d075102db21266e4ed390cff19ee17167d8d6dcaa90391aca4","declaration":"export function defineBundledChannelSetupEntry({ importMetaUrl, plugin, secrets, runtime, legacyStateMigrations, legacySessionSurface, registerSetupRuntime, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"defineBundledChannelSetupEntry","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} {"closureHash":"cd150287d7e286e7a0d7d6caeb05d0a1efa63b5f351f75f69ba932510a108afd","declaration":"export function loadBundledEntryExportSync(importMetaUrl: string, reference: BundledEntryModuleRef, options?: BundledEntryModuleLoadOptions): T;","entrypoint":"channel-entry-contract","exportName":"loadBundledEntryExportSync","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"function","recordType":"export"} {"closureHash":"7781a3d570d08929af5b3b6cffbb9ef4b9c8e8f1bed55234e0015a681868c812","declaration":"export type AnyAgentTool = AnyAgentTool;","entrypoint":"channel-entry-contract","exportName":"AnyAgentTool","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} -{"closureHash":"fa5a588c0ce180e7a20042afea7ea0159187359a8642759d7009f3debdca5ae0","declaration":"export type BundledChannelEntryContract = BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} +{"closureHash":"685f95aaa394a91f73185ac30346f2046707aa09c77e4cfc27a100bc3ec23f15","declaration":"export type BundledChannelEntryContract = BundledChannelEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"f4b82dfd995bd6f08b232984743389d1c854866cb845a8dbb3605acb28818688","declaration":"export type BundledChannelEntryFeatures = BundledChannelEntryFeatures;","entrypoint":"channel-entry-contract","exportName":"BundledChannelEntryFeatures","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"960693591b9bdc974bf3f0097076d90490078db3dceb35349a4f851736cde4fc","declaration":"export type BundledChannelLegacySessionSurface = BundledChannelLegacySessionSurface;","entrypoint":"channel-entry-contract","exportName":"BundledChannelLegacySessionSurface","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"edf79e6a736e5c4977e3b86ef21e7fdf0c0b2bc7deacd8edbee2ca76cb8cef43","declaration":"export type BundledChannelLegacyStateMigrationDetector = BundledChannelLegacyStateMigrationDetector;","entrypoint":"channel-entry-contract","exportName":"BundledChannelLegacyStateMigrationDetector","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} -{"closureHash":"8bf37d6eeb66e312f279aa9230c6f35058758d2748e0e336bc43265d961cfce2","declaration":"export type BundledChannelSetupEntryContract = BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelSetupEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} +{"closureHash":"f95a404850bcbf1c6ec8227f92ecdf94e65f96de06f63f2f993a5d838b831f62","declaration":"export type BundledChannelSetupEntryContract = BundledChannelSetupEntryContract;","entrypoint":"channel-entry-contract","exportName":"BundledChannelSetupEntryContract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"24679ecdf2ff34157b6345a37b58ec1363ec8cb1969b522599d0d92bb7a113b7","declaration":"export type BundledChannelSetupEntryFeatures = BundledChannelSetupEntryFeatures;","entrypoint":"channel-entry-contract","exportName":"BundledChannelSetupEntryFeatures","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"367219c821915e5be4d1ba3a86754475a5f8a4194512f0401de3be11cc649c60","declaration":"export type BundledEntryModuleLoadOptions = BundledEntryModuleLoadOptions;","entrypoint":"channel-entry-contract","exportName":"BundledEntryModuleLoadOptions","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} -{"closureHash":"83a2ebce6f822a66006deff2ec1d23f7fde3e1da68e23d5c3d57d152f857726a","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-entry-contract","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} +{"closureHash":"fcee79acac847664aaaa8af8392ae3797cd55ac1e38dfbb622fafa5b7d3cb0bf","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-entry-contract","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"e54f9b270df967e0f857de804dbe989f2c55b637c449c9948c4baae8eee18c7b","declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"channel-entry-contract","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"closureHash":"568b5ab033b4970cedd3916d948d31bcdd3c19ea8928c7cd7dc7abf7623b8af8","declaration":"export type PluginCommandContext = PluginCommandContext;","entrypoint":"channel-entry-contract","exportName":"PluginCommandContext","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract","kind":"type","recordType":"export"} {"category":null,"entrypoint":"channel-feedback","importSpecifier":"openclaw/plugin-sdk/channel-feedback","recordType":"module"} @@ -1103,7 +1103,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-message","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-message","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} -{"closureHash":"50a03b18eb2b53c0e4f3aea18130b93e600214baf98686ce749addf6f8004a66","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} +{"closureHash":"49e98f675c3f385cf2acea1d1b7480f1a510449c1a04bf3d65475b9a7492d2dd","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-message","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-message","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-message","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-message","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-message","kind":"type","recordType":"export"} @@ -1232,7 +1232,7 @@ {"closureHash":"9555a73ba2717bba0bf94888017be4cecef2c51cc3887ed34f8d8ef3c79d6abf","declaration":"export const DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS: number;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_DEAD_LETTER_MIN_AGE_MS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"3a2df3ab656149630c004d2d9f71b8ceda99aa81eb6b72b0cc7b3982ed0ce044","declaration":"export const DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS: 8;","entrypoint":"channel-outbound","exportName":"DEFAULT_INGRESS_RETRY_MAX_ATTEMPTS","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"38c735a14c65067aa7825390d1c5c8876c651d0603e84980dfaf947f5235747a","declaration":"export const INGRESS_CLAIM_PROCESS_ID: string;","entrypoint":"channel-outbound","exportName":"INGRESS_CLAIM_PROCESS_ID","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} -{"closureHash":"50a03b18eb2b53c0e4f3aea18130b93e600214baf98686ce749addf6f8004a66","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} +{"closureHash":"49e98f675c3f385cf2acea1d1b7480f1a510449c1a04bf3d65475b9a7492d2dd","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"channel-outbound","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"const","recordType":"export"} {"closureHash":"2c52eff200a5b9bd2d93ee72209c2626bbe165f59b538591db661b94773023b0","declaration":"export type AgentPlanStep = AgentPlanStep;","entrypoint":"channel-outbound","exportName":"AgentPlanStep","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"1e8bcb2c8d8f5327f5b81b8c217f722f090dccba21ba3943324010f4f80a5a50","declaration":"export type AgentPlanStepStatus = AgentPlanStepStatus;","entrypoint":"channel-outbound","exportName":"AgentPlanStepStatus","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} {"closureHash":"b036fc26e5330dfd7da1af8839bb3c23e85283b9e16e252a659dd4bb0c35cd5e","declaration":"export type ChannelDeliveryStreamingConfig = ChannelDeliveryStreamingConfig;","entrypoint":"channel-outbound","exportName":"ChannelDeliveryStreamingConfig","importSpecifier":"openclaw/plugin-sdk/channel-outbound","kind":"type","recordType":"export"} @@ -1298,7 +1298,7 @@ {"closureHash":"cd6b0d3f962c8cb40d405e92bb06bb8e9285b386200897f550d21036022262c1","declaration":"export const PAIRING_APPROVED_MESSAGE: \"✅ OpenClaw access approved. Send a message to start chatting.\";","entrypoint":"channel-plugin-common","exportName":"PAIRING_APPROVED_MESSAGE","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"const","recordType":"export"} {"closureHash":"712435252d2fe5135542ca6b5a4375a6edea24e83829338011dc6ae7e9802fbb","declaration":"export type ChannelMessageActionContext = ChannelMessageActionContext;","entrypoint":"channel-plugin-common","exportName":"ChannelMessageActionContext","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} {"closureHash":"37b373e93fe7f86fdbce119947c9e49b17e71ce50fe47e118c4d078c5cea20ec","declaration":"export type ChannelPlugin = ChannelPlugin;","entrypoint":"channel-plugin-common","exportName":"ChannelPlugin","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-plugin-common","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"channel-plugin-common","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} {"closureHash":"4180509522e17f8afdf5014bc51f5601952dfa403503e66f8f53e92b0c6ddd45","declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"channel-plugin-common","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common","kind":"type","recordType":"export"} {"category":null,"entrypoint":"channel-policy","importSpecifier":"openclaw/plugin-sdk/channel-policy","recordType":"module"} {"closureHash":"017ab29d16d0f3d02d60f3513e65a373868d26496bc93d020d8fc8c17fd875f4","declaration":"export function buildAccountScopedDmSecurityPolicy(params: { cfg: OpenClawConfig; channelKey: string; accountId?: string | null; fallbackAccountId?: string | null; policy?: string | null; allowFrom?: Array | null; defaultPolicy?: string; allowFromPathSuffix?: string; policyPathSuffix?: string; approveChannelId?: string; approveHint?: string; normalizeEntry?: (raw: string) => string; inheritSharedDefaultsFromDefaultAccount?: boolean; }): ChannelSecurityDmPolicy;","entrypoint":"channel-policy","exportName":"buildAccountScopedDmSecurityPolicy","importSpecifier":"openclaw/plugin-sdk/channel-policy","kind":"function","recordType":"export"} @@ -1897,8 +1897,8 @@ {"closureHash":"ed8c322fac76968f7f059b65eaa881098ed918b22705b27101166b052213a9d9","declaration":"export function createChatChannelPlugin(params: { base: ChatChannelPluginBase; security?: ChannelSecurityAdapter | ChatChannelSecurityOptions; pairing?: ChannelPairingAdapter | ChatChannelPairingOptions; threading?: ChannelThreadingAdapter | ChatChannelThreadingOptions; outbound?: ChannelOutboundAdapter | ChatChannelAttachedOutboundOptions; }): ChannelPlugin;","entrypoint":"core","exportName":"createChatChannelPlugin","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"785b0f750c5bd20f9f3ca081886a5af79ee0e4d2c38f78f769bf994b234f5076","declaration":"export function createDedupeCache(options: DedupeCacheOptions): DedupeCache;","entrypoint":"core","exportName":"createDedupeCache","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"e2df8d235401aebf58eb71652fbeaedb42ca00aaa5042a76909ff2c48ba6fd54","declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"core","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} -{"closureHash":"20eebe7796477e813273c70382bb74de3f830573f2a84500e969305ad6f7d251","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} -{"closureHash":"60eaea4ee788df80eb81ba87d49e396cb9ffb40eb7d6bfd74fbcd817a34f3dcc","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} +{"closureHash":"19ee364314ffd766b8f15835014206753bc1f96af4d7084b97a0a1cc2774d4df","declaration":"export function defineChannelPluginEntry({ id, name, description, plugin, configSchema, setRuntime, registerCliMetadata, registerFull, registerCapabilities, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry;","entrypoint":"core","exportName":"defineChannelPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} +{"closureHash":"ea866f93256b2e5f8da5bad7c96511f00f9b42fa85f6689cc70ab26bde2dd0cb","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"core","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"0e0a9f6b22c433bdb1165e7707b1f9a1ddac1a544a8add00b503048cf157921f","declaration":"export function defineSetupPluginEntry(plugin: TPlugin): { plugin: TPlugin; };","entrypoint":"core","exportName":"defineSetupPluginEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"3c5ce0c3733cbb038aeb914af1df5d701f362f0521921b8abd0c9214db9cc4b8","declaration":"export function delegateCompactionToRuntime(params: Parameters[0]): Promise;","entrypoint":"core","exportName":"delegateCompactionToRuntime","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"2c282e0e308f9442bca7cdeb8e89a097531b1d54a692c9d86e5f39a8db1c2537","declaration":"export function deleteAccountFromConfigSection(params: { cfg: OpenClawConfig; sectionKey: string; accountId: string; clearBaseFields?: string[]; }): OpenClawConfig;","entrypoint":"core","exportName":"deleteAccountFromConfigSection","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} @@ -1945,7 +1945,7 @@ {"closureHash":"2e6ce1f4c1692270a00dc0cf08e2752a26739c3143f8091cb270063c6034605d","declaration":"export function tryReadSecretFileSync(filePath: string | undefined, label: string, options: CredentialFileReadOptions): string | undefined;\nexport function tryReadSecretFileSync(filePath: string | undefined, label: string, options?: FsSafeSecretFileReadOptions): string | undefined;\nexport function tryReadSecretFileSync(filePath: string, label: string, options: FsSafeSecretFileReadOptions | undefined, diagnostic: { configPath: string; }): ConfiguredCredentialResult;\nexport function tryReadSecretFileSync(filePath: string | undefined, label: string, options: FsSafeSecretFileReadOptions | undefined, diagnostic: { configPath: string; }): CredentialResult;","entrypoint":"core","exportName":"tryReadSecretFileSync","importSpecifier":"openclaw/plugin-sdk/core","kind":"function","recordType":"export"} {"closureHash":"b40933a11984425d14a7a479bda0d2cd14cbfa9371164774f39bebbf12861df7","declaration":"export const DEFAULT_ACCOUNT_ID: \"default\";","entrypoint":"core","exportName":"DEFAULT_ACCOUNT_ID","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export"} {"closureHash":null,"declaration":"export const DEFAULT_SECRET_FILE_MAX_BYTES: number;","entrypoint":"core","exportName":"DEFAULT_SECRET_FILE_MAX_BYTES","importSpecifier":"openclaw/plugin-sdk/core","kind":"const","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"core","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"core","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"d2ff4de9ee3fd111c2cc64a3ee7e557d9f161af1b6b437ee7886bcb16e426317","declaration":"export type AgentPromptGuidance = AgentPromptGuidance;","entrypoint":"core","exportName":"AgentPromptGuidance","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"2841127a90e04467adfea8126cbf03655f44ddb089ad9e0d48833c2beea39cbd","declaration":"export type AgentPromptGuidanceEntry = AgentPromptGuidanceEntry;","entrypoint":"core","exportName":"AgentPromptGuidanceEntry","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"aa0412acefe62caff12e5271ead0545504874e824380dc21cfc0566388a05f33","declaration":"export type AgentPromptSurfaceKind = \"openclaw_main\" | \"pi_main\" | \"codex_app_server\" | \"cli_backend\" | \"acp_backend\" | \"subagent\";","entrypoint":"core","exportName":"AgentPromptSurfaceKind","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} @@ -1977,10 +1977,10 @@ {"closureHash":"54ea426dde51859b572b4c1079d2b70b412096f73da2b8c265f2749d9f01cbb5","declaration":"export type NormalizedLocation = NormalizedLocation;","entrypoint":"core","exportName":"NormalizedLocation","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"core","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"5189698d102b8b064eefe1442b008312973c3994fd6b203d2205725d7ee9d118","declaration":"export type OpenClawPluginActiveModelContext = OpenClawPluginActiveModelContext;","entrypoint":"core","exportName":"OpenClawPluginActiveModelContext","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"core","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"c968e96cdb5c97e92d26b7b15fb5255531b45a12e7c5c0a2e7e456d31cc6e358","declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"core","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"365beb5cee974639c2f35fc7a39088721ca8e9c618442830298b98033e9c768c","declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"core","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} -{"closureHash":"0fd3922f47a01a793d0b383cf6744734aae5c4fc2990344c2ed7b0969ae1d309","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} +{"closureHash":"05cefddc1e544e0efd37301f07a5adae39f6cc580239b314c1959b8887a23ce9","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"core","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"a7ed83f0924f818b23cecb5a1f3591ff28b9499ccac9667a2b8505c2bc608ea9","declaration":"export type OpenClawPluginGatewayEventScope = OpenClawPluginGatewayEventScope;","entrypoint":"core","exportName":"OpenClawPluginGatewayEventScope","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"f81f224d9b837e6353b7a546c5a76b124bcf568fd1f1fd0c6881fda30c53120a","declaration":"export type OpenClawPluginGatewayEvents = OpenClawPluginGatewayEvents;","entrypoint":"core","exportName":"OpenClawPluginGatewayEvents","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} {"closureHash":"ddc2738dd0ece94ddde9a7c655f92c15ec6416645ecf932d4aa404c7b7761159","declaration":"export type OpenClawPluginService = OpenClawPluginService;","entrypoint":"core","exportName":"OpenClawPluginService","importSpecifier":"openclaw/plugin-sdk/core","kind":"type","recordType":"export"} @@ -2215,7 +2215,7 @@ {"closureHash":"0db968dad99d84ecef5202167daadd092e3b4f456270283d3fd61585ac2bbd5a","declaration":"export type DiscordComponentSendResult = DiscordComponentSendResult;","entrypoint":"discord","exportName":"DiscordComponentSendResult","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"084b46d7dd510eb182a4705559251e824220b57dbfa6dc54392507b72ba1815e","declaration":"export type DiscordOutboundTargetResolution = DiscordOutboundTargetResolution;","entrypoint":"discord","exportName":"DiscordOutboundTargetResolution","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"discord","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"discord","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"discord","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"4180509522e17f8afdf5014bc51f5601952dfa403503e66f8f53e92b0c6ddd45","declaration":"export type PluginRuntime = PluginRuntime;","entrypoint":"discord","exportName":"PluginRuntime","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"6b9967e919c1cd420c8356b4f9d40dbc81932b4b15a733eecdcfb2ad84f8e5d8","declaration":"export type ResolvedDiscordAccount = ResolvedDiscordAccount;","entrypoint":"discord","exportName":"ResolvedDiscordAccount","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} {"closureHash":"6bed5ca896b7034117e4757e2c6b4111dd5293dbad9416e3828dd6afa4e858ce","declaration":"export type ThreadBindingRecord = ThreadBindingRecord;","entrypoint":"discord","exportName":"ThreadBindingRecord","importSpecifier":"openclaw/plugin-sdk/discord","kind":"type","recordType":"export"} @@ -2360,7 +2360,7 @@ {"closureHash":"98e0cc16352b7dfcceb4957f8cd2acfe3c2b3acb223c42a5cfb885e088fcb04c","declaration":"export function resolveInboundReplyDispatchCounts(result: ChannelTurnDispatchResultLike): Record;","entrypoint":"inbound-reply-dispatch","exportName":"resolveInboundReplyDispatchCounts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"4cd63266d379acc2cdf9aba02f50b8ceb55500cf820787c370fc98d173fb1950","declaration":"export function runChannelInboundEvent(params: RunChannelTurnParams): Promise>;\nexport function runChannelInboundEvent(params: ChannelInboundEventRunnerParams): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runChannelInboundEvent","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} {"closureHash":"2c68496a174872b31aaf1ac8fad2789deb79d490c5ffbcb157325f1c95d1b51f","declaration":"export function runPreparedInboundReply(params: PreparedChannelTurn): Promise>;","entrypoint":"inbound-reply-dispatch","exportName":"runPreparedInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"function","recordType":"export"} -{"closureHash":"50a03b18eb2b53c0e4f3aea18130b93e600214baf98686ce749addf6f8004a66","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} +{"closureHash":"49e98f675c3f385cf2acea1d1b7480f1a510449c1a04bf3d65475b9a7492d2dd","declaration":"export const deliverInboundReplyWithMessageSendContext: (params: DurableInboundReplyDeliveryParams) => Promise<{ status: \"not_applicable\"; reason: \"non_final\";} | { status: \"unsupported\"; reason: \"missing_channel\" | \"missing_target\" | \"missing_outbound_handler\" | \"capability_mismatch\"; capability?: DurableFinalDeliveryRequirement;} | { status: \"handled_visible\"; delivery: ChannelDeliveryResult;} | { status: \"handled_no_send\"; reason: \"no_visible_result\"; delivery: ChannelDeliveryResult;} | { status: \"failed\"; error: unknown; sentBeforeError?: true;}>;","entrypoint":"inbound-reply-dispatch","exportName":"deliverInboundReplyWithMessageSendContext","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"const","recordType":"export"} {"closureHash":"ab30d0470348e3af7c18a273afc0d169d91f204fba557c75c4694e8a0ea6c936","declaration":"export type AssembledInboundReply = AssembledChannelTurn;","entrypoint":"inbound-reply-dispatch","exportName":"AssembledInboundReply","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"3de0d6d1bb8f9a83bd1c6e57aa8618e3fea71054236cd530c555e348bd0b7702","declaration":"export type ChannelBotLoopProtectionFacts = ChannelBotLoopProtectionFacts;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelBotLoopProtectionFacts","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} {"closureHash":"f0da845bbdf49b30f6718a4b7ff012a0536deb98f65418583555dc90825797ca","declaration":"export type ChannelInboundDroppedHistoryOptions = ChannelTurnDroppedHistoryOptions;","entrypoint":"inbound-reply-dispatch","exportName":"ChannelInboundDroppedHistoryOptions","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch","kind":"type","recordType":"export"} @@ -3195,19 +3195,19 @@ {"closureHash":"3362ea0b650933db6db467ef8ae1c82e682e946761647c8dfae401eec1237bd6","declaration":"export function callMeetingBrowserProxyOnNode(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; } & MeetingBrowserRequestParams): Promise;","entrypoint":"meeting-runtime","exportName":"callMeetingBrowserProxyOnNode","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c5fcd1d157edcb4773235adfaf8051e3ec7ca428cb7916b6d55f2511affab56c","declaration":"export function convertMeetingBridgeAudioForStt(audio: Buffer, audioFormat: MeetingRealtimeAudioFormat): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingBridgeAudioForStt","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aa9063c27c5ff714d3653571c4a2a71e9d2eea5609b161f03e4738e15411358b","declaration":"export function convertMeetingTtsAudioForBridge(audio: Buffer, sampleRate: number, audioFormat: MeetingRealtimeAudioFormat, outputFormat?: string, platformName?: string): Buffer;","entrypoint":"meeting-runtime","exportName":"convertMeetingTtsAudioForBridge","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"100fe96a062f631b3d357570ee03626683a69465aeb2afee265a31e7153a31d9","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"41a29ffc111d6672c56e82aa27884261d3d23aded7b02186e1acd9817f450fa1","declaration":"export function createLocalMeetingRealtimeAudioTransport(params: { inputCommand: string[]; outputCommand: string[]; bargeInInputCommand?: string[]; bargeInRmsThreshold: number; bargeInPeakThreshold: number; bargeInCooldownMs: number; logger: RuntimeLogger; logScope: string; audioFormat?: MeetingRealtimeAudioFormat; spawn?: MeetingRealtimeAudioSpawn; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createLocalMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"0f81bc377d2158b9c8adb6878e454f34c2e57d53bdef9aa34d567d279800c240","declaration":"export function createMeetingBrowserNodeCaller(params: { runtime: PluginRuntime; adapter: NodeAdapter; nodeId: string; }): MeetingBrowserRequestCaller;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeCaller","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f244ca8c47f556c9f388953cf9a7581c7d4d413af93e0dfa1462779e6a687a7c","declaration":"export function createMeetingBrowserNodeInvokePolicy(options: MeetingBrowserNodePolicyOptions): OpenClawPluginNodeInvokePolicy;","entrypoint":"meeting-runtime","exportName":"createMeetingBrowserNodeInvokePolicy","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"1aa4e3ff39a2cbe162421c62b1fa4a362973381d4d9ff15c671249b1ebd566bc","declaration":"export function createMeetingNodeHost(options: MeetingNodeHostOptions): { handleCommand(paramsJSON?: string | null): Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingNodeHost","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"909a4ea24585b49a91095ada5ab95e0aa7d741a4554f757ddab8ef45385af1ee","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"7bbd4d7df1e47521e1c2a00bb1f228a7aebc93ae0a9b5fa5ed66c9c24c8a4166","declaration":"export function createMeetingRealtimeEngineBindings(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { agentId?: string; toolPolicy: RealtimeVoiceAgentConsultToolPolicy; }; }; fullConfig: OpenClawConfig; runtime: PluginRuntime; logger: RuntimeLogger; }): { platform: MeetingRuntimePlatform; consultAgent: (consult: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (call: MeetingRealtimeToolCallParams) => Promise; };","entrypoint":"meeting-runtime","exportName":"createMeetingRealtimeEngineBindings","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"9124db05923060c467edb149bd8abdc8be1d00d00f867cf84600972923a58489","declaration":"export function createMeetingSession(params: { platform: MeetingPlatformRuntimeMetadata; config: { realtime: { provider?: string; voiceProvider?: string; transcriptionProvider?: string; model?: string; toolPolicy: TToolPolicy; }; }; resolved: MeetingResolvedJoin; createdAt: string; }): MeetingSessionRecord;","entrypoint":"meeting-runtime","exportName":"createMeetingSession","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"fb276e07e395ab19177b3d7967fd0a8fcda79130883f15092a4a306b826481b9","declaration":"export function createMeetingSetupStatus(checks: MeetingSetupCheck[]): MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"createMeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"0457ac9dc4b0b2d11cdbe2070d75ae2e5c2fe5cee69017e92f849849cd06b0f3","declaration":"export function createMeetingVoiceCallGateway(params: { config: MeetingVoiceCallConfig; runtime: PluginRuntime; surface: MeetingVoiceCallSurface; connectClient: (params: { config: MeetingVoiceCallConfig; surface: MeetingVoiceCallSurface; }) => Promise; }): MeetingVoiceCallGateway;","entrypoint":"meeting-runtime","exportName":"createMeetingVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"34fdb56d1bcb289d35cdde3b9818a22020cec7a9de7de6e4a2ce028769650e1a","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"153b475879020471ce39741d83aacf36f31aed63a58897eae9f8ae902a67814c","declaration":"export function createNodeMeetingRealtimeAudioTransport(params: { runtime: PluginRuntime; nodeId: string; bridgeId: string; logger: RuntimeLogger; commandName: string; logScope: string; logPrefix: string; audioFormat?: MeetingRealtimeAudioFormat; }): MeetingRealtimeAudioTransport;","entrypoint":"meeting-runtime","exportName":"createNodeMeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c0987b15a391a92d53d3e4e12b61eb122f9547fcf4f45478cdd3c5a8973a3e52","declaration":"export function endMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"endMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"b3ebc10fe7ab372e1317eaa8f78621cf277bba1e33fda57acf2254f6759d1e9d","declaration":"export function getMeetingVoiceCallGatewayCall(params: { gateway: MeetingVoiceCallGateway; callId: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"getMeetingVoiceCallGatewayCall","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"aac088df8e80d9ce89c45feccdb04ace3ae4417b3fcdd0f058410ddf25c96b46","declaration":"export function isMeetingVoiceCallMissingError(error: unknown): boolean;","entrypoint":"meeting-runtime","exportName":"isMeetingVoiceCallMissingError","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"d24f2c4fbb2bf5bf5d36afb40e95ec5930ac1f1c71ccd6458193ddd11c1466bd","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"2ddcdaee317e00718d3061c832e4fb4a6d1a43b43e926c6136ecd553d2989167","declaration":"export function joinMeetingViaVoiceCallGateway(params: { config: MeetingVoiceCallConfig; gateway: MeetingVoiceCallGateway; surface: MeetingVoiceCallSurface; dialInNumber: string; dtmfSequence?: string; logger?: RuntimeLogger; message?: string; requesterSessionKey?: string; agentId?: string; sessionKey?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"joinMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"bac9621cd025754fbcd4c21409b06eb0503405db81f35cac993aaf0b881ab3e1","declaration":"export function leaveMeetingWithBrowser(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; launch: boolean; meetingSessionId?: string; meetingUrl: string; tab: MeetingBrowserTab; timeoutMs: number; }): Promise<{ left: boolean; note: string; }>;","entrypoint":"meeting-runtime","exportName":"leaveMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"c7b1aeac134b2070d053adf33026d876998b852ccd65cd2c9667119b1a1abee2","declaration":"export function openMeetingWithBrowser, Mode extends string, Health extends MeetingBrowserHealth & { browserTitle?: string; browserUrl?: string; notes?: string[]; }, Transcript extends MeetingTranscriptSnapshot>(params: { adapter: BrowserAdapter; callBrowser: MeetingBrowserRequestCaller; config: MeetingBrowserControllerConfig; session: Session; }): Promise<{ launched: boolean; browser?: Health; tab?: MeetingBrowserTab; }>;","entrypoint":"meeting-runtime","exportName":"openMeetingWithBrowser","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"34858979bd248799c2215d9c7bdd5d4a6b27660c9173bfca2e50dd4373ac27db","declaration":"export function readMeetingBrowserTab(result: unknown): MeetingBrowserCandidateTab | undefined;","entrypoint":"meeting-runtime","exportName":"readMeetingBrowserTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} @@ -3218,8 +3218,8 @@ {"closureHash":"a1c367ec2dd927543d490a1393128d475e10c7bd131ffd789c80d88a2d2d0cb2","declaration":"export function resolveMeetingBrowserNodeInfo(params: { runtime: PluginRuntime; adapter: NodeAdapter; requestedNode?: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"resolveMeetingBrowserNodeInfo","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"f21196c9ea8ab90248ffdcdef3e942f1247ee2b9064ddb9757fffe352362c779","declaration":"export function resolveMeetingRealtimeAudioFormat(audioFormat: MeetingRealtimeAudioFormat): RealtimeVoiceAudioFormat;","entrypoint":"meeting-runtime","exportName":"resolveMeetingRealtimeAudioFormat","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"521dc5c262524a709cdd27cdc88e95004073da8223537bcbc73d5d423172dc2b","declaration":"export function speakMeetingViaVoiceCallGateway(params: { gateway: MeetingVoiceCallGateway; callId: string; message: string; }): Promise;","entrypoint":"meeting-runtime","exportName":"speakMeetingViaVoiceCallGateway","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"afc66af1fb900349a8f9d8bad16b33fdd83ee3fa460a966af8eb028f43148620","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} -{"closureHash":"07c791b011bbbe133c0180c2c2c9975369282e0897f295be5a04beb30c2d9c16","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"d30a62bb6292da6e0da6cbd96032084122428ec76c16fb0b711813a1cd7d386e","declaration":"export function startMeetingAgentRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeTranscriptionProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingAgentRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} +{"closureHash":"0e6b815a67f214774c65fff9589717c3f0a3e6327a1c0ecce7c405a9bb627b64","declaration":"export function startMeetingRealtimeEngine(params: { config: MeetingRealtimeEngineConfig; fullConfig: OpenClawConfig; runtime: PluginRuntime; platform: MeetingRuntimePlatform; meetingSessionId: string; requesterSessionKey?: string; logPrefix?: \"node\"; talkSessionId?: string; talkContext?: { nodeId: string; bridgeId: string; }; transport: MeetingRealtimeAudioTransport; logger: RuntimeLogger; providers?: RealtimeVoiceProviderPlugin[]; consultAgent: (params: MeetingAgentConsultParams) => Promise<{ text: string; }>; tools: RealtimeVoiceTool[]; handleToolCall: (params: MeetingRealtimeToolCallParams) => Promise; }): Promise;","entrypoint":"meeting-runtime","exportName":"startMeetingRealtimeEngine","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"function","recordType":"export"} {"closureHash":"663b9c77231b21af697efd5042e22d1c33e9ee972adeb312ad86d81a2b2e36f7","declaration":"export type MeetingAgentConsultParams = MeetingAgentConsultParams;","entrypoint":"meeting-runtime","exportName":"MeetingAgentConsultParams","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"adf0745c611bf737fc28978d766fc2a25b7104e71b6b2fed4e9b95e519c7b115","declaration":"export type MeetingBrowserCandidateTab = MeetingBrowserCandidateTab;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserCandidateTab","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"59b1526172b7491b85f14f13c1ee26d8d6726f33093770748580c767b1689a17","declaration":"export type MeetingBrowserControllerConfig = MeetingBrowserControllerConfig;","entrypoint":"meeting-runtime","exportName":"MeetingBrowserControllerConfig","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3251,7 +3251,7 @@ {"closureHash":"8244b408803f57cf2004157e96f2ce11bb036cdf864123b15ba2fac7f03b5d43","declaration":"export type MeetingSessionRuntimeHandles = MeetingSessionRuntimeHandles;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeHandles","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"aa8054fc2c442891197e89bdad61e79999ea15d813c4b127fb6150607b9e67e7","declaration":"export type MeetingSessionRuntimeJoinContext, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab> = MeetingSessionRuntimeJoinContext;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeJoinContext","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"325241724edd38da439c5df03d225e2a7cb8e56d817e98ed7c37850656d3744b","declaration":"export type MeetingSessionRuntimeMessages = MeetingSessionRuntimeMessages;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeMessages","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"b0575ac4d37523c2a2a427931c64c2f8b89fcd2a779eedad66f43efe77b6b1ac","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} +{"closureHash":"9140b7d74ad1d6db84343684461ee4328a4f977b1e071843a5fcc6fe4a0593c7","declaration":"export type MeetingSessionRuntimeOptions, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> = MeetingSessionRuntimeOptions;","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntimeOptions","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"d3c730cba02d30512c4cdae6e2367c662982e2c25671833ecd8701283924c642","declaration":"export type MeetingSessionState = MeetingSessionState;","entrypoint":"meeting-runtime","exportName":"MeetingSessionState","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"2496931157bf64fdb16013172ab9c023e60c5f85b98f46a3ee9532ce58d636d6","declaration":"export type MeetingSetupCheck = MeetingSetupCheck;","entrypoint":"meeting-runtime","exportName":"MeetingSetupCheck","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"f0aed6f7955a43fc387d8bbc072cb6276b0f2d18e7f829441a2ad45974bb3831","declaration":"export type MeetingSetupStatus = MeetingSetupStatus;","entrypoint":"meeting-runtime","exportName":"MeetingSetupStatus","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} @@ -3265,9 +3265,9 @@ {"closureHash":"20c70a94483aeddda4d1500cfcc5e71fab844b474ba4061c55d6c8693d40821b","declaration":"export type MeetingVoiceCallJoinResult = MeetingVoiceCallJoinResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallJoinResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"4fb2c5928aa6f809dd4c604d71c5c99cdfe3e98b6e9c1662601c9b4ad9738162","declaration":"export type MeetingVoiceCallStatusResult = MeetingVoiceCallStatusResult;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallStatusResult","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} {"closureHash":"5ef812397b6409a4ca228943dd1080661365959477b42e705a9aaa219757d14f","declaration":"export type MeetingVoiceCallSurface = MeetingVoiceCallSurface;","entrypoint":"meeting-runtime","exportName":"MeetingVoiceCallSurface","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"type","recordType":"export"} -{"closureHash":"dd91b64c44f2f66a2b3a36610e50ba1b10638db372bfbd17eb30be636f704da6","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} +{"closureHash":"0f7612ffa6432932130e8e6f83d267c98466eb46a91d43fc8c037c2303e63e05","declaration":"export interface MeetingPlatformAdapter extends MeetingPlatformAdapterContract {\n}","entrypoint":"meeting-runtime","exportName":"MeetingPlatformAdapter","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} {"closureHash":"79884bd1df1ea0d07d5ceb4c62d93c1ee744b75d31c94c988845869c0f3cbd2d","declaration":"export interface MeetingRealtimeAudioTransport {\n onFatal(handler: () => void): void;\n startInput(onAudio: (audio: Buffer) => void): void;\n beginOutput?(): void;\n stop(): Promise;\n writeOutput(audio: Buffer): Promise;\n clearOutput(): Promise;\n dispose(): Promise;\n getHealth?(): MeetingRealtimeAudioTransportHealth;\n startBargeInMonitor?(onBargeIn: (audio: Buffer) => boolean): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingRealtimeAudioTransport","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"interface","recordType":"export"} -{"closureHash":"bcc4e7d34932202ad3805c54ac67cb466924d2c0374a33e6a2a02ffa518ce3ca","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} +{"closureHash":"9e91eff317b13d93107e856c077bd51db614f2aa65d3c7d4c60e86ae09f489b6","declaration":"export class MeetingSessionRuntime, TRequest, TTransport extends string, TMode extends string, THealth extends MeetingBrowserHealth, TTab extends MeetingBrowserTab, TManualReason extends string, TSpeechBlockedReason extends string> {\n readonly #sessions: Map;\n readonly #sessionLeaves: Map>>;\n readonly #sessionCleanup: MeetingSessionCleanupTracker;\n readonly #meetingLock: MeetingSessionJoinLock;\n readonly #sessionStops: Map Promise>;\n readonly #sessionSpeakers: Map void>;\n readonly #sessionHealth: Map Partial>;\n readonly #durableTranscripts: MeetingSessionDurableTranscripts;\n readonly #transcriptStore: MeetingSessionTranscriptStore;\n constructor(private readonly options: MeetingSessionRuntimeOptions);\n list(): TSession[];\n getSession(sessionId: string): TSession | undefined;\n async status(sessionId?: string): Promise<{\n found: boolean;\n session?: TSession;\n sessions?: TSession[];\n }>;\n async transcript(sessionId: string, options: {\n sinceIndex?: number;\n }): Promise<{ found: boolean; sessionId?: string; startIndex?: number; nextIndex?: number; droppedLines?: number; evicted?: boolean; lines?: MeetingTranscriptLine[]; }>;\n async startTranscriptSource(request: TranscriptStartRequest): Promise;\n async stopTranscriptSource(request: TranscriptStopRequest): Promise;\n isReusableSession(session: TSession, resolved: MeetingResolvedJoin): boolean;\n async join(request: TRequest): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async leave(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async speak(sessionId: string, instructions?: string): Promise<{\n found: boolean;\n spoken: boolean;\n session?: TSession;\n }>;\n async speakWhenReady(session: TSession, instructions: string): Promise;\n hasHealthHandle(sessionId: string): boolean;\n refreshHealth(sessionId?: string): void;\n async refreshBrowserHealth(session: TSession, options: {\n force?: boolean;\n readOnly?: boolean;\n }): Promise;\n async refreshCaptionHealth(session: TSession): Promise;\n refreshSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n markSessionEnded(session: TSession, reason: string): void;\n async #joinUnlocked(request: TRequest, resolved: MeetingResolvedJoin): Promise<{\n session: TSession;\n spoken?: boolean;\n }>;\n async #leaveUnlocked(sessionId: string, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n async #leaveSession(session: TSession, options?: {\n keepBrowserTab?: boolean;\n }): Promise>;\n #meetingKey(transport: TTransport, url: string): string;\n #inheritBrowserTabOwnership(params: {\n session: TSession;\n transport: TTransport;\n nodeId?: string;\n meetingUrl: string;\n tab?: TTab;\n }): TTab | undefined;\n async #settleRetainedBrowserTabs(retained: Array<{\n session: TSession;\n tab: TTab;\n }>, adopted?: {\n transport: TTransport;\n nodeId?: string;\n tab: TTab;\n }): Promise;\n async #rollbackFailedJoinSession(session: TSession): Promise;\n async #settleRetainedBrowserTabsAfterFailure(retained: Array<{\n session: TSession;\n tab: TTab;\n }>): Promise;\n #attachRuntimeHandles(session: TSession, handles: MeetingSessionRuntimeHandles): void;\n #dropRuntimeHandles(sessionId: string): void;\n #isManagedBrowserSession(session: TSession): boolean;\n #evaluateSpeechReadiness(session: TSession): {\n ready: boolean;\n reason?: TSpeechBlockedReason;\n message?: string;\n };\n #noteSession(session: TSession, note: string): void;\n}","entrypoint":"meeting-runtime","exportName":"MeetingSessionRuntime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime","kind":"class","recordType":"export"} {"category":null,"entrypoint":"memory-core-host-engine-foundation","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","recordType":"module"} {"closureHash":"e2df8d235401aebf58eb71652fbeaedb42ca00aaa5042a76909ff2c48ba6fd54","declaration":"export function createSubsystemLogger(subsystem: string): SubsystemLogger;","entrypoint":"memory-core-host-engine-foundation","exportName":"createSubsystemLogger","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} {"closureHash":null,"declaration":"export function isPathInside(root: string, target: string): boolean;","entrypoint":"memory-core-host-engine-foundation","exportName":"isPathInside","importSpecifier":"openclaw/plugin-sdk/memory-core-host-engine-foundation","kind":"function","recordType":"export"} @@ -3403,9 +3403,9 @@ {"category":"core","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","recordType":"module"} {"closureHash":"3d27406644141af2733cfca6e8c1240fc62327910109f585ce9073cafd7fd89c","declaration":"export function buildJsonPluginConfigSchema(schema: JsonSchemaObject, options?: BuildJsonPluginConfigSchemaOptions): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"buildJsonPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} {"closureHash":"aff66c792d532642afb9c6e5a03c8d220f219250c3a8b49733f32058f22f98ca","declaration":"export function buildPluginConfigSchema(schema: ZodTypeAny, options?: BuildPluginConfigSchemaOptions): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"buildPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} -{"closureHash":"60eaea4ee788df80eb81ba87d49e396cb9ffb40eb7d6bfd74fbcd817a34f3dcc","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} +{"closureHash":"ea866f93256b2e5f8da5bad7c96511f00f9b42fa85f6689cc70ab26bde2dd0cb","declaration":"export function definePluginEntry({ id, name, description, kind, configSchema, reload, nodeHostCommands, securityAuditCollectors, register, }: DefinePluginEntryOptions): DefinedPluginEntry;","entrypoint":"plugin-entry","exportName":"definePluginEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} {"closureHash":"0ef1dfa3b5deeb456480e292c0e835c3cd6d13e7bb7059fc735546507da51594","declaration":"export function emptyPluginConfigSchema(): OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"emptyPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"function","recordType":"export"} -{"closureHash":"7dcb6a9a37ace260567e3b46fc8d051aa124df2edfb093dfb56f082da56eaafb","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"plugin-entry","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} +{"closureHash":"beb6715ad81487caa2cc2ed8444d6ac2dcf83c9e71fc42d5db9214f2eb12b946","declaration":"export type AgentHarness = AgentHarness;","entrypoint":"plugin-entry","exportName":"AgentHarness","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"d2ff4de9ee3fd111c2cc64a3ee7e557d9f161af1b6b437ee7886bcb16e426317","declaration":"export type AgentPromptGuidance = AgentPromptGuidance;","entrypoint":"plugin-entry","exportName":"AgentPromptGuidance","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"2841127a90e04467adfea8126cbf03655f44ddb089ad9e0d48833c2beea39cbd","declaration":"export type AgentPromptGuidanceEntry = AgentPromptGuidanceEntry;","entrypoint":"plugin-entry","exportName":"AgentPromptGuidanceEntry","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"aa0412acefe62caff12e5271ead0545504874e824380dc21cfc0566388a05f33","declaration":"export type AgentPromptSurfaceKind = \"openclaw_main\" | \"pi_main\" | \"codex_app_server\" | \"cli_backend\" | \"acp_backend\" | \"subagent\";","entrypoint":"plugin-entry","exportName":"AgentPromptSurfaceKind","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} @@ -3421,10 +3421,10 @@ {"closureHash":"8054ffbe71b91afced900ef8a06f5e368ac10c4f99ffc1c6f538a802856f61c6","declaration":"export type OpenClawConfig = OpenClawConfig;","entrypoint":"plugin-entry","exportName":"OpenClawConfig","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"2f6e368fff73e942044cdf95c7c751b46c741e22ac0d2d4d97f45a3158f758ae","declaration":"export type OpenClawGatewayDiscoveryAdvertiseContext = OpenClawGatewayDiscoveryAdvertiseContext;","entrypoint":"plugin-entry","exportName":"OpenClawGatewayDiscoveryAdvertiseContext","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"b198058e0065e5c60b729a5bdf568f7e2436a006d8c4a7c63de764c7283f96ba","declaration":"export type OpenClawGatewayDiscoveryService = OpenClawGatewayDiscoveryService;","entrypoint":"plugin-entry","exportName":"OpenClawGatewayDiscoveryService","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-entry","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"c968e96cdb5c97e92d26b7b15fb5255531b45a12e7c5c0a2e7e456d31cc6e358","declaration":"export type OpenClawPluginCommandDefinition = OpenClawPluginCommandDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginCommandDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"365beb5cee974639c2f35fc7a39088721ca8e9c618442830298b98033e9c768c","declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-entry","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} -{"closureHash":"0fd3922f47a01a793d0b383cf6744734aae5c4fc2990344c2ed7b0969ae1d309","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} +{"closureHash":"05cefddc1e544e0efd37301f07a5adae39f6cc580239b314c1959b8887a23ce9","declaration":"export type OpenClawPluginDefinition = OpenClawPluginDefinition;","entrypoint":"plugin-entry","exportName":"OpenClawPluginDefinition","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"a7ed83f0924f818b23cecb5a1f3591ff28b9499ccac9667a2b8505c2bc608ea9","declaration":"export type OpenClawPluginGatewayEventScope = OpenClawPluginGatewayEventScope;","entrypoint":"plugin-entry","exportName":"OpenClawPluginGatewayEventScope","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"f81f224d9b837e6353b7a546c5a76b124bcf568fd1f1fd0c6881fda30c53120a","declaration":"export type OpenClawPluginGatewayEvents = OpenClawPluginGatewayEvents;","entrypoint":"plugin-entry","exportName":"OpenClawPluginGatewayEvents","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} {"closureHash":"7470b211ac4d3449cd0fc353ee2eb48e9fbc2dbc9259d21c680d0203577aa94f","declaration":"export type OpenClawPluginHttpRouteHandler = OpenClawPluginHttpRouteHandler;","entrypoint":"plugin-entry","exportName":"OpenClawPluginHttpRouteHandler","importSpecifier":"openclaw/plugin-sdk/plugin-entry","kind":"type","recordType":"export"} @@ -3574,14 +3574,14 @@ {"closureHash":"ad45bb1eb13e99e3217eb7e7e2d29fa456bc1930059dd5e94d7500e05e54c6bc","declaration":"export function executePluginCommand(params: { command: RegisteredPluginCommand; args?: string; senderId?: string; channel: string; channelId?: PluginCommandContext[\"channelId\"]; isAuthorizedSender: boolean; senderIsOwner?: boolean; gatewayClientScopes?: PluginCommandContext[\"gatewayClientScopes\"]; agentId?: string; sessionKey?: PluginCommandContext[\"sessionKey\"]; sessionId?: PluginCommandContext[\"sessionId\"]; sessionTarget?: PluginCommandContext[\"sessionTarget\"]; sessionFile?: PluginCommandContext[\"sessionFile\"]; authProfileId?: string; commandBody: string; config: OpenClawConfig; from?: PluginCommandContext[\"from\"]; to?: PluginCommandContext[\"to\"]; originatingTo?: string; accountId?: PluginCommandContext[\"accountId\"]; messageThreadId?: PluginCommandContext[\"messageThreadId\"]; threadParentId?: PluginCommandContext[\"threadParentId\"]; diagnosticsSessions?: PluginCommandContext[\"diagnosticsSessions\"]; diagnosticsUploadApproved?: PluginCommandContext[\"diagnosticsUploadApproved\"]; diagnosticsPreviewOnly?: PluginCommandContext[\"diagnosticsPreviewOnly\"]; diagnosticsPrivateRouted?: PluginCommandContext[\"diagnosticsPrivateRouted\"]; }): Promise;","entrypoint":"plugin-runtime","exportName":"executePluginCommand","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"7e75fc04d621dc8d12f459d5b5b0c039a39b77c30c2b51af481e123c352f57b8","declaration":"export function getGlobalHookRunner(): HookRunner | null;","entrypoint":"plugin-runtime","exportName":"getGlobalHookRunner","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"db6583607237e4081814e2c5d4f8fb6f216ad3bbfe623bf16fc1a9c09c15f64b","declaration":"export function getPluginCommandSpecs(provider?: string, options?: PluginCommandSpecOptions): Array<{ name: string; description: string; descriptionLocalizations?: Record; acceptsArgs: boolean; }>;","entrypoint":"plugin-runtime","exportName":"getPluginCommandSpecs","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} -{"closureHash":"501447aa669a7dd46a644791d7374819a7e1d5b3cfffac3251a8d5a3efb9d1b5","declaration":"export function getPluginRuntimeGatewayRequestScope(): PluginRuntimeGatewayRequestScope | undefined;","entrypoint":"plugin-runtime","exportName":"getPluginRuntimeGatewayRequestScope","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} +{"closureHash":"dbfeaea0c67177141cc6e662d6df7d97cac656a9079377c514bffb89073f3c8e","declaration":"export function getPluginRuntimeGatewayRequestScope(): PluginRuntimeGatewayRequestScope | undefined;","entrypoint":"plugin-runtime","exportName":"getPluginRuntimeGatewayRequestScope","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"cfe277ce6de0c6ed50f1f54b66c889f6c95e91b058e8bcdc27aa37317a93a447","declaration":"export function listRegisteredPluginAgentPromptGuidance(params?: { surface?: AgentPromptSurfaceKind; includeLegacyGlobalGuidance?: boolean; }): string[];","entrypoint":"plugin-runtime","exportName":"listRegisteredPluginAgentPromptGuidance","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"693462c2a5c07646277169ede37b71ed12dc8d56c648b7fed73712900be34dcf","declaration":"export function matchPluginCommand(commandBody: string, options?: { channel?: string; }): { command: RegisteredPluginCommand; args?: string; } | null;","entrypoint":"plugin-runtime","exportName":"matchPluginCommand","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"8d575a525d165780642e70ff1385311c3422429c2e26c479a85baae491d62efe","declaration":"export function registerPluginCommand(pluginId: string, command: OpenClawPluginCommandDefinition, opts?: { pluginName?: string; pluginRoot?: string; allowReservedCommandNames?: boolean; allowOwnerStatusExposure?: boolean; }): CommandRegistrationResult;","entrypoint":"plugin-runtime","exportName":"registerPluginCommand","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"16986e5d431f584c2d5053289eca3d2225bbe47a92cdfbbc2af91b264a165846","declaration":"export function registerPluginInteractiveHandler(pluginId: string, registration: PluginInteractiveHandlerRegistration, opts?: { pluginName?: string; pluginRoot?: string; }): InteractiveRegistrationResult;","entrypoint":"plugin-runtime","exportName":"registerPluginInteractiveHandler","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"f20c07a48e2c949fbc889e7a32760f4e52339d9f6d3eab49e00ff249266e2396","declaration":"export function startLazyPluginServiceModule(params: { skipEnvVar?: string; overrideEnvVar?: string; validateOverrideSpecifier?: (specifier: string) => string; loadDefaultModule: () => Promise; loadOverrideModule?: (specifier: string) => Promise; startExportNames: string[]; stopExportNames?: string[]; }): Promise;","entrypoint":"plugin-runtime","exportName":"startLazyPluginServiceModule","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"function","recordType":"export"} {"closureHash":"bad43ffe69d1d9d6c92c79df4c388b3dda685013a92392daef456f1622ade659","declaration":"export type LazyPluginServiceHandle = LazyPluginServiceHandle;","entrypoint":"plugin-runtime","exportName":"LazyPluginServiceHandle","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} -{"closureHash":"6545dfa8438683bf5ab321b0fe5cca9e47fc91686fe2166f8abc9084ce170aac","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-runtime","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} +{"closureHash":"ff621688b7b58ebfa7157b153d83921a47158e43ac312ff61df85d437216ae10","declaration":"export type OpenClawPluginApi = OpenClawPluginApi;","entrypoint":"plugin-runtime","exportName":"OpenClawPluginApi","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} {"closureHash":"365beb5cee974639c2f35fc7a39088721ca8e9c618442830298b98033e9c768c","declaration":"export type OpenClawPluginConfigSchema = OpenClawPluginConfigSchema;","entrypoint":"plugin-runtime","exportName":"OpenClawPluginConfigSchema","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} {"closureHash":"f06379ada10ce0eca4e66bdf978db2c0035e054a135dc044c6d831ac7797c08f","declaration":"export type PluginConversationBinding = PluginConversationBinding;","entrypoint":"plugin-runtime","exportName":"PluginConversationBinding","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} {"closureHash":"a1223d674b2b66918a576ae05b04318d7b8cbe9b1d3bc6dd7a08d1a4fa79b291","declaration":"export type PluginConversationBindingRequestParams = PluginConversationBindingRequestParams;","entrypoint":"plugin-runtime","exportName":"PluginConversationBindingRequestParams","importSpecifier":"openclaw/plugin-sdk/plugin-runtime","kind":"type","recordType":"export"} @@ -3671,10 +3671,10 @@ {"closureHash":"e66adcb55dec79be73a52bc76594b7e9210fd93c69a85b56098fbb58cecbb354","declaration":"export type WriteOAuthCredentialsOptions = WriteOAuthCredentialsOptions;","entrypoint":"provider-auth","exportName":"WriteOAuthCredentialsOptions","importSpecifier":"openclaw/plugin-sdk/provider-auth","kind":"type","recordType":"export"} {"category":null,"entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","recordType":"module"} {"closureHash":"b5665596a3f011454a5614681513516acd50b6c749536c43842d811980cf7ad3","declaration":"export function augmentModelCatalogWithProviderPlugins(params: { config?: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; metadataSnapshot?: PluginMetadataSnapshot; context: ProviderAugmentModelCatalogContext; }): Promise;","entrypoint":"provider-catalog-runtime","exportName":"augmentModelCatalogWithProviderPlugins","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"d3d9275ea2612b0f8837fe12d4b14cd3e3d06e3aed4c3417754a3b1c95e39e25","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"5d83dd4ba5e9f0b8c1d9ffaf583f5312c88e21d5cb90637ac0f260133265f738","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"4c281c99ff58abf4ab164909c5528ec9c7d33b10fd660bf340b028111b616c6f","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} -{"closureHash":"048abfae415819c157f5a6f7fbdab5f0a17f48863f973a7ac361c7a645e6ec5b","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"a360cb1f899330116af2fd6f2370cc53c0ff5083bd27a4d5e5977c05fa4627a6","declaration":"export function isPluginProvidersLoadInFlight(params: Parameters[0]): boolean;","entrypoint":"provider-catalog-runtime","exportName":"isPluginProvidersLoadInFlight","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"a8a3abd31253da5e27e43db29968d818f98a55a6f08e0be1e7e94265c634f7fa","declaration":"export function resolveCatalogHookProviderPluginIds(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; metadataSnapshot?: ProviderManifestLoadParams[\"metadataSnapshot\"]; }): string[];","entrypoint":"provider-catalog-runtime","exportName":"resolveCatalogHookProviderPluginIds","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"0d463b51098a4f8bb1ca72db875efc938f64978bbf498632bdc7fb8b61648f4a","declaration":"export function resolveOwningPluginIdsForProvider(params: { provider: string; config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; manifestRegistry?: PluginManifestRegistry; metadataSnapshot?: Pick; }): string[] | undefined;","entrypoint":"provider-catalog-runtime","exportName":"resolveOwningPluginIdsForProvider","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} +{"closureHash":"4e83218d414f08a78be577d566d883f6147e50776a128e6c3e9c49057ab93cdc","declaration":"export function resolvePluginProviders(params: { config?: PluginLoadOptions[\"config\"]; workspaceDir?: string; env?: PluginLoadOptions[\"env\"]; bundledProviderVitestCompat?: boolean; onlyPluginIds?: string[]; providerRefs?: readonly string[]; modelRefs?: readonly string[]; activate?: boolean; cache?: boolean; applyAutoEnable?: boolean; pluginSdkResolution?: PluginLoadOptions[\"pluginSdkResolution\"]; mode?: \"runtime\" | \"setup\"; includeUntrustedWorkspacePlugins?: boolean; pluginMetadataSnapshot?: PluginMetadataRegistryView; skipIfLoadInFlight?: boolean; }): ProviderPlugin[];","entrypoint":"provider-catalog-runtime","exportName":"resolvePluginProviders","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime","kind":"function","recordType":"export"} {"category":null,"entrypoint":"proxy-capture","importSpecifier":"openclaw/plugin-sdk/proxy-capture","recordType":"module"} {"closureHash":"d0e4e026d736bc00b1758d1089c44c3c5f3a09c7254291584de8cb297365804c","declaration":"export function acquireDebugProxyCaptureStore(dbPath: string, blobDir: string): { store: LegacyDebugProxyCaptureStore; release: () => void; };\nexport function acquireDebugProxyCaptureStore(options?: DebugProxyCaptureStoreOptions): { store: SharedDebugProxyCaptureStore; release: () => void; };","entrypoint":"proxy-capture","exportName":"acquireDebugProxyCaptureStore","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} {"closureHash":"d41cbb9de51b7aee1c6cf8c713b0bc44f27ac926fbb6422c05726f196d11964e","declaration":"export function captureHttpExchange(params: { url: string; method: string; requestHeaders?: Headers | Record | undefined; requestBody?: BodyInit | Buffer | string | null; response: Response; transport?: \"http\" | \"sse\"; flowId?: string; meta?: Record; }, resolved?: DebugProxySettings, deps?: DebugProxyCaptureRuntimeDeps): void;","entrypoint":"proxy-capture","exportName":"captureHttpExchange","importSpecifier":"openclaw/plugin-sdk/proxy-capture","kind":"function","recordType":"export"} @@ -4303,7 +4303,7 @@ {"closureHash":"eb612aa7bb0e0ac71925a219bb93f04867caec9eafa8c46e118433b7fc7651e6","declaration":"export let STATE_DIR: string;","entrypoint":"state-paths","exportName":"STATE_DIR","importSpecifier":"openclaw/plugin-sdk/state-paths","kind":"variable","recordType":"export"} {"category":null,"entrypoint":"status-helpers","importSpecifier":"openclaw/plugin-sdk/status-helpers","recordType":"module"} {"closureHash":"da271ab51c875f3b557969c3fed4b62459b26cf511fff81be0f37be71a7dc1db","declaration":"export function appendMatchMetadata(message: string, params: { matchKey?: unknown; matchSource?: unknown; }): string;","entrypoint":"status-helpers","exportName":"appendMatchMetadata","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} -{"closureHash":"feb6b6cc6719326e5f09c3ed6c38083e178f7ca9fb5dae74e90ab0f50811bcf6","declaration":"export function asString(value: unknown): string | undefined;","entrypoint":"status-helpers","exportName":"asString","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} +{"closureHash":"c0c92d709ca55c2e9c2d579eed61ce2203c9046bcbf6699a0d0bb3a72287d477","declaration":"export function asString(value: unknown): string | undefined;","entrypoint":"status-helpers","exportName":"asString","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} {"closureHash":"36929d73a3e6a600c84a9f80a68403194e0669e4ffa146306f9b9627253c2e47","declaration":"export function buildBaseAccountStatusSnapshot(params: { account: { accountId: string; name?: string; enabled?: boolean; configured?: boolean; }; runtime?: RuntimeLifecycleSnapshot | null; probe?: unknown; }, extra?: TExtra): { lastInboundAt: number | null; lastOutboundAt: number | null; activeRunStartedAt?: number | undefined; lastRunActivityAt?: number | undefined; activeRuns?: number | undefined; busy?: boolean | undefined; terminalDisconnect?: true | undefined; ingressUnavailable?: true | undefined; lifecycle?: \"blocked\" | \"starting\" | \"ready\" | \"recovering\" | \"stopped\" | undefined; healthState?: string | undefined; lastTransportActivityAt?: number | undefined; lastEventAt?: number | undefined; lastDisconnect?: string | { at: number; status?: number; error?: string; loggedOut?: boolean; } | undefined; lastConnectedAt?: number | undefined; reconnectAttempts?: number | undefined; restartPending?: boolean | undefined; connected?: boolean | undefined; linked?: boolean | undefined; running: boolean; lastStartAt: number | null; lastStopAt: number | null; lastError: string | null; probe: unknown; accountId: string; name: string | undefined; enabled: boolean | undefined; configured: boolean | undefined; } & TExtra;","entrypoint":"status-helpers","exportName":"buildBaseAccountStatusSnapshot","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} {"closureHash":"0bf8ae29b2d9d8623e8a34f219f4e078c8f6e1c01cfd9fae07176500563e803b","declaration":"export function buildBaseChannelStatusSummary(snapshot: { configured?: boolean | null; running?: boolean | null; lastStartAt?: number | null; lastStopAt?: number | null; lastError?: string | null; }, extra?: TExtra): { configured: boolean; } & TExtra & { running: boolean; lastStartAt: number | null; lastStopAt: number | null; lastError: string | null; };","entrypoint":"status-helpers","exportName":"buildBaseChannelStatusSummary","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} {"closureHash":"091884288f381f9d44c4f98f991990eedf0ca4b62af623b97beb0c17fe9ec6a6","declaration":"export function buildComputedAccountStatusSnapshot(params: { accountId: string; name?: string; enabled?: boolean; configured?: boolean; runtime?: RuntimeLifecycleSnapshot | null; probe?: unknown; }, extra?: TExtra): { lastInboundAt: number | null; lastOutboundAt: number | null; activeRunStartedAt?: number | undefined; lastRunActivityAt?: number | undefined; activeRuns?: number | undefined; busy?: boolean | undefined; terminalDisconnect?: true | undefined; ingressUnavailable?: true | undefined; lifecycle?: \"blocked\" | \"starting\" | \"ready\" | \"recovering\" | \"stopped\" | undefined; healthState?: string | undefined; lastTransportActivityAt?: number | undefined; lastEventAt?: number | undefined; lastDisconnect?: string | { at: number; status?: number; error?: string; loggedOut?: boolean; } | undefined; lastConnectedAt?: number | undefined; reconnectAttempts?: number | undefined; restartPending?: boolean | undefined; connected?: boolean | undefined; linked?: boolean | undefined; running: boolean; lastStartAt: number | null; lastStopAt: number | null; lastError: string | null; probe: unknown; accountId: string; name: string | undefined; enabled: boolean | undefined; configured: boolean | undefined; } & TExtra;","entrypoint":"status-helpers","exportName":"buildComputedAccountStatusSnapshot","importSpecifier":"openclaw/plugin-sdk/status-helpers","kind":"function","recordType":"export"} @@ -4333,7 +4333,9 @@ {"closureHash":"c4a67c4e399ef77063c033fa6408ab52201677b3e91d18cbf72c2b0ef3b04868","declaration":"export function asBoolean(value: unknown): boolean | undefined;","entrypoint":"string-coerce-runtime","exportName":"asBoolean","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"8cc60d63f99d6ed490bb90c379e40689b7a7360b5731c47622dbca57646867c7","declaration":"export function asFiniteNumber(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"asFiniteNumber","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"91afebd87438e96391af3fe271f3c0297be88678420987a648e0ab2073fec8cb","declaration":"export function asFiniteNumberInRange(value: unknown, range: { min?: number; max?: number; minExclusive?: boolean; maxExclusive?: boolean; }): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"asFiniteNumberInRange","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} +{"closureHash":"8f5f89cd550e17fde19f6229edd7502cd473384edc289f448032a066f27d8139","declaration":"export function asNullableObjectRecord(value: unknown): Record | null;","entrypoint":"string-coerce-runtime","exportName":"asNullableObjectRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"4c772989882503a1861c55c9ebac600ceaf218d44147d87da0628eba845bff15","declaration":"export function asNullableRecord(value: unknown): Record | null;","entrypoint":"string-coerce-runtime","exportName":"asNullableRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} +{"closureHash":"3ce3856918e2e7e45071d174e5948657ba2cf18bfedc6fe1ac9d7d859012f6b2","declaration":"export function asOptionalObjectRecord(value: unknown): Record | undefined;","entrypoint":"string-coerce-runtime","exportName":"asOptionalObjectRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"1627961066a987bec6f21c1cebccf61ded3c26ae834f5819e1909632a5586834","declaration":"export function asOptionalRecord(value: unknown): Record | undefined;","entrypoint":"string-coerce-runtime","exportName":"asOptionalRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"41c29f2f42fa1a43f9f4a56dbe9ccd3c87232ffc4b7046bb15848f0150e5a1a4","declaration":"export function asPositiveSafeInteger(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"asPositiveSafeInteger","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"28f70710cc0d9006bc47149d51fc65a6c990f1c2f7c9a16f96d94fa593041bcb","declaration":"export function asRecord(value: unknown): Record;","entrypoint":"string-coerce-runtime","exportName":"asRecord","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} @@ -4361,6 +4363,7 @@ {"closureHash":"37287f8f868dd5f82766f20dd47e2c83aea94022461d2b27ad1fbe97a5c7cca8","declaration":"export function normalizeUniqueStringEntries(values?: Iterable): string[];","entrypoint":"string-coerce-runtime","exportName":"normalizeUniqueStringEntries","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"70c7cea3e68443c494677f7012c4aae2d3be3a4b586d777e299273230fa3a460","declaration":"export function normalizeUniqueTrimmedStringList(value: unknown): string[];","entrypoint":"string-coerce-runtime","exportName":"normalizeUniqueTrimmedStringList","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"b4468874f0640fe53b525195faa47195b7946cb28e2edbee566dc00d53d81ed5","declaration":"export function parseBooleanValue(value: unknown, options?: BooleanParseOptions): boolean | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseBooleanValue","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} +{"closureHash":"8e6357d6b3d286570af672d24cc93cbe84875a4c49c4142b6ba283687015799a","declaration":"export function parseDateTimestampMs(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseDateTimestampMs","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"1b4c281d27d98dd358c598879e048447d3be1855a4e7f27dc769e20cdef38a10","declaration":"export function parseFiniteNumber(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseFiniteNumber","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"35e6fd636ae00132a4b61439bd6932cd2f2f7b3d3870f239f7d3935babcce365","declaration":"export function parseStrictFiniteNumber(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseStrictFiniteNumber","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} {"closureHash":"0eecec70484482da2e1775cd09bd90c1a1b7a5541b6fe77ea28ce355c475becd","declaration":"export function parseStrictInteger(value: unknown): number | undefined;","entrypoint":"string-coerce-runtime","exportName":"parseStrictInteger","importSpecifier":"openclaw/plugin-sdk/string-coerce-runtime","kind":"function","recordType":"export"} @@ -4636,16 +4639,16 @@ {"closureHash":"e365d7ad911453b5139ba700931b061e560cff80b50d4cbe5e9615b5d7e13a52","declaration":"export type ScopedExpiringIdCache = ScopedExpiringIdCache;","entrypoint":"text-runtime","exportName":"ScopedExpiringIdCache","importSpecifier":"openclaw/plugin-sdk/text-runtime","kind":"type","recordType":"export"} {"closureHash":"af5502c92594c2e9049a4b4994e17c00d3adee3f6d1e68edd818a539c74c8787","declaration":"export interface CodeRegion {\n start: number;\n end: number;\n}","entrypoint":"text-runtime","exportName":"CodeRegion","importSpecifier":"openclaw/plugin-sdk/text-runtime","kind":"interface","recordType":"export"} {"category":null,"entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin","recordType":"module"} -{"closureHash":"1b4fb3ffd40b901d362f1e8e4824a82cf8526ef00ef846974f13254e1c0dffa5","declaration":"export function defineToolPlugin(definition: DefineToolPluginOptions): DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"defineToolPlugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"function","recordType":"export"} +{"closureHash":"0a3affca237e0370b796f418bfbe87212c7f1dc7d8066586a113c8d3f1c9c183","declaration":"export function defineToolPlugin(definition: DefineToolPluginOptions): DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"defineToolPlugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"function","recordType":"export"} {"closureHash":"36ef907ce20f885b5f3068cbf0475dc442a68da9652579bb5bf8fb83ad34c38b","declaration":"export function getToolPluginMetadata(entry: unknown): ToolPluginMetadata | undefined;","entrypoint":"tool-plugin","exportName":"getToolPluginMetadata","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"function","recordType":"export"} {"closureHash":"bdc58154e36b2625fb3daccca3eab248d981077d04d747f0ad507cb2ff565198","declaration":"export const toolPluginMetadataSymbol: typeof toolPluginMetadataSymbol;","entrypoint":"tool-plugin","exportName":"toolPluginMetadataSymbol","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"const","recordType":"export"} -{"closureHash":"bb1c06399667b2257908a8f507e882da468aa6c3ff6fd6b038e8a4338b594441","declaration":"export type DefineToolPluginOptions = DefineToolPluginOptions;","entrypoint":"tool-plugin","exportName":"DefineToolPluginOptions","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"c4eec6214d34f00b92ad6e3c995f2bafe2ecc652ee616b98ba38fb870f9d81ea","declaration":"export type DefinedToolPluginEntry = DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"DefinedToolPluginEntry","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"870ea60b2645fb6af508f6a2a588fd6e53836bc187b3aa6cb519bb1553d20e82","declaration":"export type ToolPluginExecutionContext = ToolPluginExecutionContext;","entrypoint":"tool-plugin","exportName":"ToolPluginExecutionContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"dff06f936a371772c6fe219d33fd974f8bb778b5e96f9d92de14dc2f57886e94","declaration":"export type ToolPluginFactoryContext = ToolPluginFactoryContext;","entrypoint":"tool-plugin","exportName":"ToolPluginFactoryContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"11aa5fbcec024a768b7af0c54f8fc899c202ff892edf481ba1476ff895345abb","declaration":"export type DefineToolPluginOptions = DefineToolPluginOptions;","entrypoint":"tool-plugin","exportName":"DefineToolPluginOptions","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"7daed12952abd818d864a6e96c60c90bb79ddb73a94f50f3e21c676dbdb3dbd7","declaration":"export type DefinedToolPluginEntry = DefinedToolPluginEntry;","entrypoint":"tool-plugin","exportName":"DefinedToolPluginEntry","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"15bac4724ce2f1cfb2aebdc5f66cd10de56c84f916bf65169ce1bfed39866e2d","declaration":"export type ToolPluginExecutionContext = ToolPluginExecutionContext;","entrypoint":"tool-plugin","exportName":"ToolPluginExecutionContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"84872c7903f1cb60ab330047241d2bca21e43ee84f2e1073fd78cf3aa6b56dcb","declaration":"export type ToolPluginFactoryContext = ToolPluginFactoryContext;","entrypoint":"tool-plugin","exportName":"ToolPluginFactoryContext","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} {"closureHash":"f774bd092f51a31fbb34847213f9583234f585f66918d0af55e81bbbae40463b","declaration":"export type ToolPluginMetadata = ToolPluginMetadata;","entrypoint":"tool-plugin","exportName":"ToolPluginMetadata","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} {"closureHash":"414a89d0c80eaaf682dc25d306e7223c981c6fd25366adb973a106f4268a6a54","declaration":"export type ToolPluginStaticToolMetadata = ToolPluginStaticToolMetadata;","entrypoint":"tool-plugin","exportName":"ToolPluginStaticToolMetadata","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} -{"closureHash":"372dc6fb47a6060961e287821d9e87b59359ace16d0b9fb38347205a9f9ab5d6","declaration":"export type ToolPluginToolDefinition = ToolPluginToolDefinition;","entrypoint":"tool-plugin","exportName":"ToolPluginToolDefinition","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} +{"closureHash":"11e44768d91eaaa804d40fd52e29e5a93d24698515dcd50bb22f48c542b92f9a","declaration":"export type ToolPluginToolDefinition = ToolPluginToolDefinition;","entrypoint":"tool-plugin","exportName":"ToolPluginToolDefinition","importSpecifier":"openclaw/plugin-sdk/tool-plugin","kind":"type","recordType":"export"} {"category":null,"entrypoint":"tool-results","importSpecifier":"openclaw/plugin-sdk/tool-results","recordType":"module"} {"closureHash":"c5b0951c3629834dff78f92317cf1421a6bbc0faf9307be1cabb915055665280","declaration":"export function jsonResult(payload: TDetails): AgentToolResult;","entrypoint":"tool-results","exportName":"jsonResult","importSpecifier":"openclaw/plugin-sdk/tool-results","kind":"function","recordType":"export"} {"closureHash":"b9aefc637a694c74e028e92006004bef18185ed569af48ac43d36b646b0af2f3","declaration":"export function textResult(text: string, details: TDetails): AgentToolResult;","entrypoint":"tool-results","exportName":"textResult","importSpecifier":"openclaw/plugin-sdk/tool-results","kind":"function","recordType":"export"} @@ -4679,9 +4682,9 @@ {"closureHash":"9f9174f1023a8a451232f5d97d1e922b7faa88b76b58cffbfbe246a1488e0ddd","declaration":"export function readJsonWebhookBodyOrReject(params: { req: IncomingMessage; res: ServerResponse; maxBytes?: number; timeoutMs?: number; profile?: WebhookBodyReadProfile; emptyObjectOnEmpty?: boolean; invalidJsonMessage?: string; }): Promise<{ ok: true; value: unknown; } | { ok: false; }>;","entrypoint":"webhook-ingress","exportName":"readJsonWebhookBodyOrReject","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"1bbddd5d1c8297e315275db4d5e1c8748ff09c0be7c37b0cf456db4fc69f8909","declaration":"export function readRequestBodyWithLimit(req: IncomingMessage, options: ReadRequestBodyOptions): Promise;","entrypoint":"webhook-ingress","exportName":"readRequestBodyWithLimit","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"e8a1174cac83ce346a231fd25e82bb5f7618ffb57cdd9af6eaa208411f70646d","declaration":"export function readWebhookBodyOrReject(params: { req: IncomingMessage; res: ServerResponse; maxBytes?: number; timeoutMs?: number; profile?: WebhookBodyReadProfile; invalidBodyMessage?: string; }): Promise<{ ok: true; value: string; } | { ok: false; }>;","entrypoint":"webhook-ingress","exportName":"readWebhookBodyOrReject","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} -{"closureHash":"d958ba4df86fc65fd5fdbabd463e4b6ab5a572f28c350dffa1f987f64c8280bd","declaration":"export function registerPluginHttpRoute(params: { path?: string | null; fallbackPath?: string | null; handler: PluginHttpRouteHandler; auth: PluginHttpRouteRegistration[\"auth\"]; match?: PluginHttpRouteRegistration[\"match\"]; gatewayRuntimeScopeSurface?: PluginHttpRouteRegistration[\"gatewayRuntimeScopeSurface\"]; replaceExisting?: boolean; reuseExistingSameOwner?: boolean; throwOnFailure?: boolean; pluginId?: string; source?: string; accountId?: string; log?: (message: string) => void; registry?: PluginRegistry; }): () => void;","entrypoint":"webhook-ingress","exportName":"registerPluginHttpRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} +{"closureHash":"16a1cd83bd12e1f265a4b2e2fabe6c4d108e9570ee88c7daaf020608a5007f25","declaration":"export function registerPluginHttpRoute(params: { path?: string | null; fallbackPath?: string | null; handler: PluginHttpRouteHandler; auth: PluginHttpRouteRegistration[\"auth\"]; match?: PluginHttpRouteRegistration[\"match\"]; gatewayRuntimeScopeSurface?: PluginHttpRouteRegistration[\"gatewayRuntimeScopeSurface\"]; replaceExisting?: boolean; reuseExistingSameOwner?: boolean; throwOnFailure?: boolean; pluginId?: string; source?: string; accountId?: string; log?: (message: string) => void; registry?: PluginRegistry; }): () => void;","entrypoint":"webhook-ingress","exportName":"registerPluginHttpRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"05835f2f66f906f736b82b332a84a9714bc3a6b211890a62d8fc4dbf9258a7d4","declaration":"export function registerWebhookTarget(targetsByPath: Map, target: T, opts?: RegisterWebhookTargetOptions): RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"registerWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} -{"closureHash":"2dd6ae1adcd4f011a2969c4a990622dc764078e775304eac40c9991864091a86","declaration":"export function registerWebhookTargetWithPluginRoute(params: { targetsByPath: Map; target: T; route: RegisterWebhookPluginRouteOptions; onLastPathTargetRemoved?: RegisterWebhookTargetOptions[\"onLastPathTargetRemoved\"]; }): RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"registerWebhookTargetWithPluginRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} +{"closureHash":"3a78257a9745ef83d912a6d4659ff16a67d2eab326098f6084119ef510f915a7","declaration":"export function registerWebhookTargetWithPluginRoute(params: { targetsByPath: Map; target: T; route: RegisterWebhookPluginRouteOptions; onLastPathTargetRemoved?: RegisterWebhookTargetOptions[\"onLastPathTargetRemoved\"]; }): RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"registerWebhookTargetWithPluginRoute","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"ec2f47e9bd7a075a3dd4302ac9e4c0bfe20630445db6735cf803f2d354275841","declaration":"export function requestBodyErrorToText(code: RequestBodyLimitErrorCode): string;","entrypoint":"webhook-ingress","exportName":"requestBodyErrorToText","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"5e1f3b468d56eb0f82104184077fdf4b6bd0c1444aa10b71a65fcaa2ec0298a2","declaration":"export function resolveRequestClientIp(req?: IncomingMessage, trustedProxies?: string[], allowRealIpFallback?: boolean): string | undefined;","entrypoint":"webhook-ingress","exportName":"resolveRequestClientIp","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} {"closureHash":"56c3e9868dfa6ee2a7a128eb02039a16b52696759adadbd3fe0a3bd7a6a07eeb","declaration":"export function resolveSingleWebhookTarget(targets: readonly T[], isMatch: (target: T) => boolean): WebhookTargetMatchResult;","entrypoint":"webhook-ingress","exportName":"resolveSingleWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"function","recordType":"export"} @@ -4699,7 +4702,7 @@ {"closureHash":"efb74e84339c15d6f607b38d1d477b0504d47d04ae78e77083a1893c756a0b54","declaration":"export const WEBHOOK_RATE_LIMIT_DEFAULTS: Readonly<{ windowMs: 60000; maxRequests: 120; maxTrackedKeys: 4096;}>;","entrypoint":"webhook-ingress","exportName":"WEBHOOK_RATE_LIMIT_DEFAULTS","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"const","recordType":"export"} {"closureHash":"13b28ec9acae43c3bdcd683b9831154f88118dee8142e42b7eebfd59f08c602c","declaration":"export type BoundedCounter = BoundedCounter;","entrypoint":"webhook-ingress","exportName":"BoundedCounter","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"9e737ae8c17038ef7becbd2bf1b756cfeaed571e2ded368c0fe3a446ebd1f666","declaration":"export type FixedWindowRateLimiter = FixedWindowRateLimiter;","entrypoint":"webhook-ingress","exportName":"FixedWindowRateLimiter","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} -{"closureHash":"c78ccdc3cb8e45f5da08133b6cb7beef96a37484c85f2eb9d018b5e78aa6d1d7","declaration":"export type RegisterWebhookPluginRouteOptions = RegisterWebhookPluginRouteOptions;","entrypoint":"webhook-ingress","exportName":"RegisterWebhookPluginRouteOptions","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} +{"closureHash":"75b1673b974b3866ffe5ad188f96f05a191310fcd95ff97efbcac5829e9eafdc","declaration":"export type RegisterWebhookPluginRouteOptions = RegisterWebhookPluginRouteOptions;","entrypoint":"webhook-ingress","exportName":"RegisterWebhookPluginRouteOptions","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"b3e98619854462acfac6538ac9a2d0cefb88704a0eac712563f0996b07364152","declaration":"export type RegisterWebhookTargetOptions = RegisterWebhookTargetOptions;","entrypoint":"webhook-ingress","exportName":"RegisterWebhookTargetOptions","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"964c2bfd8f268c411ee7b982c9de02c207f4bec402dd62c9f998a59752bb9d1f","declaration":"export type RegisteredWebhookTarget = RegisteredWebhookTarget;","entrypoint":"webhook-ingress","exportName":"RegisteredWebhookTarget","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} {"closureHash":"aa552e5bdaefc9eb94d3b5a5cdf8c413fad555d9f452c6a2a6af5a3ce695c9d1","declaration":"export type WebhookAnomalyTracker = WebhookAnomalyTracker;","entrypoint":"webhook-ingress","exportName":"WebhookAnomalyTracker","importSpecifier":"openclaw/plugin-sdk/webhook-ingress","kind":"type","recordType":"export"} diff --git a/extensions/acpx/src/pi-session-catalog.ts b/extensions/acpx/src/pi-session-catalog.ts index 22e0427c76d1..f306a3bcd5a8 100644 --- a/extensions/acpx/src/pi-session-catalog.ts +++ b/extensions/acpx/src/pi-session-catalog.ts @@ -7,6 +7,7 @@ import type { import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { listPiSummaryPage, readPiSessionById } from "./pi-session-store.js"; +import { parsePiSessionTimestampMs } from "./pi-session-timestamp.js"; const LOCAL_HOST_ID = "gateway"; const DEFAULT_PAGE_LIMIT = 20; @@ -161,17 +162,6 @@ function textFromContent(content: unknown): string { .join("\n"); } -function timestampMs(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; -} - function parseListParams(value: unknown): { searchTerm?: string; limit: number; cursor?: string } { if (value === undefined || value === null) { return { limit: DEFAULT_PAGE_LIMIT }; @@ -236,7 +226,8 @@ function isoTimestamp( message: Record, entry: Record, ): string | undefined { - const value = timestampMs(message.timestamp) ?? timestampMs(entry.timestamp); + const value = + parsePiSessionTimestampMs(message.timestamp) ?? parsePiSessionTimestampMs(entry.timestamp); if (value === undefined) { return undefined; } diff --git a/extensions/acpx/src/pi-session-paths.ts b/extensions/acpx/src/pi-session-paths.ts index 18532f404781..1ef8e2357129 100644 --- a/extensions/acpx/src/pi-session-paths.ts +++ b/extensions/acpx/src/pi-session-paths.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -function optionalString(value: unknown, maxLength: number): string | undefined { +function readBoundedString(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; } @@ -52,7 +52,7 @@ function resolveConfiguredPath( function settingsSessionDir(file: string): string | undefined { try { const value = JSON.parse(readFileSync(file, "utf8")) as unknown; - return isRecord(value) ? optionalString(value.sessionDir, 4_096) : undefined; + return isRecord(value) ? readBoundedString(value.sessionDir, 4_096) : undefined; } catch { return undefined; } diff --git a/extensions/acpx/src/pi-session-store.ts b/extensions/acpx/src/pi-session-store.ts index 4f27016d523a..7bad3921f03d 100644 --- a/extensions/acpx/src/pi-session-store.ts +++ b/extensions/acpx/src/pi-session-store.ts @@ -4,6 +4,7 @@ import path from "node:path"; import type { SessionCatalogSession } from "openclaw/plugin-sdk/session-catalog"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { piAcpSessionStoreRoot, piSessionStore } from "./pi-session-paths.js"; +import { parsePiSessionTimestampMs } from "./pi-session-timestamp.js"; const MAX_DISCOVERY_FILES = 10_000; const SUMMARY_SCAN_BATCH_SIZE = 100; @@ -84,7 +85,7 @@ function cacheSummary(file: string, value: CachedSummary): void { } } -function optionalString(value: unknown, maxLength: number): string | undefined { +function readBoundedString(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; } @@ -258,17 +259,6 @@ function textFromContent(content: unknown): string { .join("\n"); } -function timestampMs(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; -} - function processSummaryLine(state: PiSummaryScanState, line: Buffer): void { const content = line.at(-1) === 0x0d ? line.subarray(0, -1) : line; const entry = parsePiJsonLines(content.toString("utf8"))[0]; @@ -285,14 +275,14 @@ function processSummaryLine(state: PiSummaryScanState, line: Buffer): void { } if (entry.type === "session_info") { // Latest metadata wins, including an explicit empty-name clear. - state.name = optionalString(entry.name, 1_000); + state.name = readBoundedString(entry.name, 1_000); } else if ( !state.firstMessage && entry.type === "message" && isRecord(entry.message) && entry.message.role === "user" ) { - state.firstMessage = optionalString(textFromContent(entry.message.content), 1_000); + state.firstMessage = readBoundedString(textFromContent(entry.message.content), 1_000); } } @@ -419,10 +409,10 @@ async function readPiSessionSummary( const { header, name, firstMessage } = projectedState; const version = header?.type === "session" && typeof header.version === "number" ? header.version : 1; - const threadId = header?.type === "session" ? optionalString(header.id, 256) : undefined; + const threadId = header?.type === "session" ? readBoundedString(header.id, 256) : undefined; if (header && threadId && SESSION_ID_PATTERN.test(threadId)) { - const cwd = optionalString(header.cwd, 4_096); - const createdAt = timestampMs(header.timestamp); + const cwd = readBoundedString(header.cwd, 4_096); + const createdAt = parsePiSessionTimestampMs(header.timestamp); summary = { file: candidate.file, version, diff --git a/extensions/acpx/src/pi-session-timestamp.test.ts b/extensions/acpx/src/pi-session-timestamp.test.ts new file mode 100644 index 000000000000..b6a77b594c0b --- /dev/null +++ b/extensions/acpx/src/pi-session-timestamp.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { listLocalPiSessionPage, readLocalPiTranscriptPage } from "./pi-session-catalog.js"; +import { createPiStoreFixture } from "./pi-session-catalog.test-support.js"; + +const temporaryDirectories: string[] = []; +const originalSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR; +const originalAgentDir = process.env.PI_CODING_AGENT_DIR; + +afterEach(async () => { + if (originalSessionDir === undefined) { + delete process.env.PI_CODING_AGENT_SESSION_DIR; + } else { + process.env.PI_CODING_AGENT_SESSION_DIR = originalSessionDir; + } + if (originalAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = originalAgentDir; + } + await Promise.all( + temporaryDirectories.splice(0).map(async (directory) => { + await fs.rm(directory, { recursive: true, force: true }); + }), + ); +}); + +describe("Pi session timestamp contract", () => { + it("preserves date parsing for numeric-looking timestamp strings", async () => { + const directory = await createPiStoreFixture(temporaryDirectories); + const entries = [ + { + type: "session", + version: 3, + id: "pi-session", + timestamp: "2026", + cwd: "/workspace", + }, + { + type: "message", + id: "user-1", + parentId: null, + timestamp: "2026", + message: { role: "user", content: "hello", timestamp: "0" }, + }, + ]; + await fs.writeFile( + path.join(directory, "session.jsonl"), + `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, + ); + + const listed = await listLocalPiSessionPage({ limit: 20 }); + expect(listed.sessions[0]?.createdAt).toBe(Date.parse("2026")); + expect(listed.sessions[0]?.createdAt).not.toBe(2_026); + + const transcript = await readLocalPiTranscriptPage({ threadId: "pi-session", limit: 20 }); + expect(transcript.items[0]?.timestamp).toBe(new Date(Date.parse("0")).toISOString()); + expect(transcript.items[0]?.timestamp).not.toBe(new Date(0).toISOString()); + }); +}); diff --git a/extensions/acpx/src/pi-session-timestamp.ts b/extensions/acpx/src/pi-session-timestamp.ts new file mode 100644 index 000000000000..b25b98ec5cdb --- /dev/null +++ b/extensions/acpx/src/pi-session-timestamp.ts @@ -0,0 +1,10 @@ +import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; + +/** Preserve Pi JSONL's date-first string contract while accepting numeric millisecond values. */ +export function parsePiSessionTimestampMs(value: unknown): number | undefined { + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return asFiniteNumber(value); +} diff --git a/extensions/acpx/src/pi-session-upstream-activity.test.ts b/extensions/acpx/src/pi-session-upstream-activity.test.ts index 137db28b4d20..3bc8a52becc0 100644 --- a/extensions/acpx/src/pi-session-upstream-activity.test.ts +++ b/extensions/acpx/src/pi-session-upstream-activity.test.ts @@ -192,6 +192,66 @@ describe("Pi session upstream activity", () => { ).resolves.toEqual([]); }); + it("preserves Pi date parsing for numeric-looking activity timestamps", async () => { + const sessionDirectory = await createPiStoreFixture( + temporaryDirectories, + "hi", + "Pi catalog session", + { command: "pwd" }, + true, + ); + const continued = await linkContinuedPiSession("agent:main:pi", "pi-session"); + const file = path.join(sessionDirectory, "session.jsonl"); + const probe = { + sessionKey: continued.sessionKey, + agentId: "main", + threadId: "pi-session", + hostId: "gateway", + upstreamKind: continued.upstream!.kind, + upstreamRef: continued.upstream!.ref, + marker: continued.upstream!.marker, + ownRecentUserTexts: [], + }; + + await fs.appendFile( + file, + `${JSON.stringify({ + type: "message", + timestamp: "0", + message: { role: "user", content: "first external Pi turn" }, + })}\n`, + ); + const first = await checkPiUpstreamActivity([probe]); + expect(first).toEqual([ + expect.objectContaining({ + kind: "activity", + humanTurns: 1, + occurredAt: Date.parse("0"), + }), + ]); + expect(first[0]?.kind === "activity" ? first[0].occurredAt : undefined).not.toBe(0); + + await fs.appendFile( + file, + `${JSON.stringify({ + type: "message", + timestamp: "2026", + message: { role: "user", content: "second external Pi turn" }, + })}\n`, + ); + await expect( + checkPiUpstreamActivity([ + { ...probe, marker: first[0]!.kind === "activity" ? first[0]!.nextMarker : null }, + ]), + ).resolves.toEqual([ + expect.objectContaining({ + kind: "activity", + humanTurns: 1, + occurredAt: Date.parse("2026"), + }), + ]); + }); + it("stops the cursor before the first unclassifiable complete row", async () => { const sessionDirectory = await createPiStoreFixture( temporaryDirectories, diff --git a/extensions/acpx/src/pi-session-upstream-activity.ts b/extensions/acpx/src/pi-session-upstream-activity.ts index 623f58d4ff1d..94fbd99b246a 100644 --- a/extensions/acpx/src/pi-session-upstream-activity.ts +++ b/extensions/acpx/src/pi-session-upstream-activity.ts @@ -8,6 +8,7 @@ import { } from "openclaw/plugin-sdk/session-catalog"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { readPiSessionFileBaseline } from "./pi-session-store.js"; +import { parsePiSessionTimestampMs } from "./pi-session-timestamp.js"; const MAX_PI_UPSTREAM_SCAN_BYTES = 1024 * 1024; @@ -72,17 +73,6 @@ function textFromContent(content: unknown): string | undefined { return text || undefined; } -function timestampMs(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? undefined : parsed; - } - return undefined; -} - function readFilePath(probe: SessionUpstreamProbe): string | undefined { return isRecord(probe.upstreamRef) && typeof probe.upstreamRef.filePath === "string" ? probe.upstreamRef.filePath @@ -168,7 +158,9 @@ async function checkPiSessionUpstreamActivity( humanTurns += 1; occurredAt = Math.max( occurredAt ?? 0, - timestampMs(entry.message.timestamp) ?? timestampMs(entry.timestamp) ?? stat.mtimeMs, + parsePiSessionTimestampMs(entry.message.timestamp) ?? + parsePiSessionTimestampMs(entry.timestamp) ?? + stat.mtimeMs, ); } const nextOffset = markerOffset + classifiedBytes; diff --git a/extensions/active-memory/config.ts b/extensions/active-memory/config.ts index da6c3aa1ced6..1d59ea87eb20 100644 --- a/extensions/active-memory/config.ts +++ b/extensions/active-memory/config.ts @@ -4,7 +4,7 @@ import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { isPathInside } from "openclaw/plugin-sdk/security-runtime"; import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeStringEntries, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -138,7 +138,7 @@ function normalizePromptConfigText(value: unknown): string | undefined { } function hasDeprecatedModelFallbackPolicy(pluginConfig: unknown): boolean { - const raw = asRecord(pluginConfig); + const raw = asOptionalRecord(pluginConfig); return raw ? Object.hasOwn(raw, "modelFallbackPolicy") : false; } diff --git a/extensions/active-memory/prompt.ts b/extensions/active-memory/prompt.ts index 29451f9f7290..58e807650a5f 100644 --- a/extensions/active-memory/prompt.ts +++ b/extensions/active-memory/prompt.ts @@ -1,5 +1,5 @@ import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -179,7 +179,7 @@ function readExplicitMemoryEvidence(source: Record): boolean | } function readStructuredMemoryFailure(source: unknown): boolean | undefined { - const record = asRecord(source); + const record = asOptionalRecord(source); if (!record) { return undefined; } @@ -206,7 +206,7 @@ function readStructuredMemoryEvidence(source: unknown): boolean | undefined { if (Array.isArray(source)) { return source.length > 0; } - const record = asRecord(source); + const record = asOptionalRecord(source); return record ? readExplicitMemoryEvidence(record) : undefined; } diff --git a/extensions/active-memory/session-policy.ts b/extensions/active-memory/session-policy.ts index 02cd296eb096..a4436dd3456e 100644 --- a/extensions/active-memory/session-policy.ts +++ b/extensions/active-memory/session-policy.ts @@ -7,7 +7,7 @@ import { } from "openclaw/plugin-sdk/plugin-config-runtime"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing"; -import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCanonicalSessionKeyFromSessionId } from "./session.js"; import { DEFAULT_AGENT_ID, @@ -108,7 +108,7 @@ function formatActiveMemoryCommandHelp(): string { } function isActiveMemoryGloballyEnabled(cfg: OpenClawConfig): boolean { - const entry = asRecord(cfg.plugins?.entries?.["active-memory"]); + const entry = asOptionalRecord(cfg.plugins?.entries?.["active-memory"]); if (entry?.enabled === false) { return false; } @@ -142,8 +142,8 @@ function updateActiveMemoryGlobalEnabledInConfig( enabled: boolean, ): OpenClawConfig { const entries = { ...cfg.plugins?.entries }; - const existingEntry = asRecord(entries["active-memory"]) ?? {}; - const existingConfig = asRecord(existingEntry.config) ?? {}; + const existingEntry = asOptionalRecord(entries["active-memory"]) ?? {}; + const existingConfig = asOptionalRecord(existingEntry.config) ?? {}; entries["active-memory"] = { ...existingEntry, enabled: true, diff --git a/extensions/active-memory/transcript-result.ts b/extensions/active-memory/transcript-result.ts index 649437f0dc79..10dc76ff2671 100644 --- a/extensions/active-memory/transcript-result.ts +++ b/extensions/active-memory/transcript-result.ts @@ -1,5 +1,5 @@ import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -35,7 +35,7 @@ function readMemoryToolResultEvidence(params: { hasUsableMemoryResult: boolean; hasUnavailableMemorySearchResult: boolean; } { - const result = asRecord(params.result); + const result = asOptionalRecord(params.result); const rawContent = result?.content; const textContent = normalizeOptionalString(result?.detailedContent) ?? @@ -63,11 +63,11 @@ function readMemoryToolResultEvidence(params: { } function extractAssistantTextFromSessionRecord(value: unknown): string { - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record) { return ""; } - const nestedMessage = asRecord(record.message); + const nestedMessage = asOptionalRecord(record.message); const topLevelMessage = normalizeOptionalString(record.role) === "assistant" ? record : undefined; const message = nestedMessage ?? topLevelMessage; if (!message || normalizeOptionalString(message.role) !== "assistant") { diff --git a/extensions/active-memory/transcript-watch.ts b/extensions/active-memory/transcript-watch.ts index 460ef71d9b18..a7e2395a6c18 100644 --- a/extensions/active-memory/transcript-watch.ts +++ b/extensions/active-memory/transcript-watch.ts @@ -1,5 +1,5 @@ import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -221,7 +221,7 @@ function watchTerminalMemorySearchResult(params: { } function normalizeSearchDebug(value: unknown): ActiveMemorySearchDebug | undefined { - const debug = asRecord(value); + const debug = asOptionalRecord(value); if (!debug) { return undefined; } @@ -255,8 +255,8 @@ function normalizeSearchDebug(value: unknown): ActiveMemorySearchDebug | undefin function readActiveMemorySearchDebugFromRunResult( result: unknown, ): ActiveMemorySearchDebug | undefined { - const record = asRecord(result); - const meta = asRecord(record?.meta); + const record = asOptionalRecord(result); + const meta = asOptionalRecord(record?.meta); return ( normalizeSearchDebug(meta?.activeMemorySearchDebug) ?? normalizeSearchDebug(meta?.memorySearchDebug) ?? @@ -266,9 +266,9 @@ function readActiveMemorySearchDebugFromRunResult( } function readActiveMemorySessionFileFromRunResult(result: unknown): string | undefined { - const record = asRecord(result); - const meta = asRecord(record?.meta); - const agentMeta = asRecord(meta?.agentMeta); + const record = asOptionalRecord(result); + const meta = asOptionalRecord(record?.meta); + const agentMeta = asOptionalRecord(meta?.agentMeta); return ( normalizeOptionalString(agentMeta?.sessionFile) ?? normalizeOptionalString(meta?.sessionFile) ); diff --git a/extensions/active-memory/transcript.ts b/extensions/active-memory/transcript.ts index 4b12d7cec528..fe1120ea7010 100644 --- a/extensions/active-memory/transcript.ts +++ b/extensions/active-memory/transcript.ts @@ -7,7 +7,7 @@ import { type SessionTranscriptTargetParams, } from "openclaw/plugin-sdk/session-transcript-runtime"; import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -176,8 +176,9 @@ async function streamActiveMemoryTranscriptRecords(params: { } function resolveToolResultMessage(value: unknown): Record | undefined { - const record = asRecord(value); - const message = asRecord(record?.message) ?? (record?.role === "toolResult" ? record : undefined); + const record = asOptionalRecord(value); + const message = + asOptionalRecord(record?.message) ?? (record?.role === "toolResult" ? record : undefined); return message && normalizeOptionalString(message.role) === "toolResult" ? message : undefined; } @@ -192,8 +193,8 @@ function extractActiveMemorySearchDebugFromSessionRecord( if (toolName !== "memory_search" && toolName !== "memory_recall") { return undefined; } - const details = asRecord(message.details); - const debug = asRecord(details?.debug); + const details = asOptionalRecord(message.details); + const debug = asOptionalRecord(details?.debug); const warning = normalizeOptionalString(details?.warning); const action = normalizeOptionalString(details?.action); const error = normalizeOptionalString(details?.error); @@ -240,7 +241,7 @@ function hasUnavailableMemoryResultInSessionRecord( if (!toolName || !toolsAllow.includes(toolName)) { return false; } - const details = asRecord(message.details); + const details = asOptionalRecord(message.details); const unavailable = message.isError === true || readStructuredMemoryFailure(details) === true; if (unavailable) { return true; @@ -260,7 +261,7 @@ function hasTerminalUnavailableMemoryResultInSessionRecord( if (!toolName || !toolsAllow.includes(toolName)) { return false; } - const details = asRecord(message.details); + const details = asOptionalRecord(message.details); if (details?.disabled === true || details?.unavailable === true) { return true; } @@ -325,7 +326,7 @@ function hasUsableMemoryResultInSessionRecord( if (hasUnavailableMemoryResultInSessionRecord(value, toolsAllow)) { return false; } - const details = asRecord(message.details); + const details = asOptionalRecord(message.details); const content = extractTextContent(message.content); if (toolName === "memory_search") { if (Array.isArray(details?.results)) { @@ -372,7 +373,7 @@ function hasUsableMemoryResultInSessionRecord( return true; } try { - const parsed = asRecord(JSON.parse(content)); + const parsed = asOptionalRecord(JSON.parse(content)); return ( typeof parsed?.expandedSummaryCount === "number" && Number.isFinite(parsed.expandedSummaryCount) && diff --git a/extensions/amazon-bedrock/embedding-provider.ts b/extensions/amazon-bedrock/embedding-provider.ts index 8d0336985b05..6b710ed0f2a4 100644 --- a/extensions/amazon-bedrock/embedding-provider.ts +++ b/extensions/amazon-bedrock/embedding-provider.ts @@ -9,7 +9,7 @@ import { type MemoryEmbeddingProviderCreateOptions, } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -263,9 +263,9 @@ function parseSingle(family: Family, raw: string): number[] { return asNumberArray(Array.isArray(data.embeddings) ? data.embeddings[0]?.embedding : null); case "twelvelabs": { if (Array.isArray(data.data)) { - return asNumberArray(asRecord(data.data[0])?.embedding); + return asNumberArray(asOptionalRecord(data.data[0])?.embedding); } - const dataRecord = asRecord(data.data); + const dataRecord = asOptionalRecord(data.data); if (dataRecord) { return asNumberArray(dataRecord.embedding); } @@ -283,7 +283,7 @@ function parseCohereBatch(family: Family, raw: string): number[][] { throw malformedBedrockEmbeddingResponse(); } if (family === "cohere-v4" && !Array.isArray(embeddings)) { - const embeddingRecord = asRecord(embeddings); + const embeddingRecord = asOptionalRecord(embeddings); if (!embeddingRecord) { throw malformedBedrockEmbeddingResponse(); } diff --git a/extensions/anthropic/session-catalog-history.test.ts b/extensions/anthropic/session-catalog-history.test.ts index 8b0ce386bc78..2ecfea87d054 100644 --- a/extensions/anthropic/session-catalog-history.test.ts +++ b/extensions/anthropic/session-catalog-history.test.ts @@ -20,7 +20,7 @@ vi.mock("openclaw/plugin-sdk/session-transcript-runtime", () => ({ })); describe("importClaudeHistory", () => { - it("falls back for invalid timestamps while preserving valid pre-epoch dates", async () => { + it("preserves Date.parse semantics for valid strings and falls back for invalid values", async () => { appended.length = 0; const fallbackTimestamp = new Date("2026-07-18T12:00:00.000Z").getTime(); vi.useFakeTimers(); @@ -29,11 +29,13 @@ describe("importClaudeHistory", () => { await importClaudeHistory({ items: [ { type: "userMessage", text: "invalid", timestamp: "not-a-date", uuid: "u-1" }, + { type: "userMessage", text: "numeric year", timestamp: "2026", uuid: "u-2" }, + { type: "userMessage", text: "numeric zero", timestamp: "0", uuid: "u-3" }, { type: "userMessage", text: "pre-epoch", timestamp: "1969-12-31T23:59:59.000Z", - uuid: "u-2", + uuid: "u-4", }, ], threadId: "thread-1", @@ -47,8 +49,13 @@ describe("importClaudeHistory", () => { vi.useRealTimers(); } - expect(appended).toHaveLength(2); - expect(appended.map((message) => message.timestamp)).toEqual([-1_000, fallbackTimestamp + 1]); + expect(appended).toHaveLength(4); + expect(appended.map((message) => message.timestamp)).toEqual([ + -1_000, + Date.parse("0"), + Date.parse("2026"), + fallbackTimestamp + 3, + ]); expect(JSON.stringify(appended)).not.toContain('"timestamp":null'); }); diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index 278d05062e0b..786f4fbe6b4b 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -544,6 +544,34 @@ describe("Claude session catalog", () => { ); }); + it("preserves date-first parsing for numeric-looking index timestamps", async () => { + const home = await createHome(); + const sessionId = "numeric-looking-timestamps"; + await writeProject({ + home, + entries: [ + { + sessionId, + created: "0", + modified: "2026", + isSidechain: false, + }, + ], + transcripts: { [sessionId]: [message(sessionId, "user", "timestamp contract", 1)] }, + }); + + await expect(listLocalClaudeSessionPage({}, home)).resolves.toMatchObject({ + sessions: [ + { + threadId: sessionId, + createdAt: Date.parse("0"), + updatedAt: Date.parse("2026"), + recencyAt: Date.parse("2026"), + }, + ], + }); + }); + it("adopts a local CLI row with a locked one-shot fork binding", async () => { const home = await createHome(); process.env.HOME = home; diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index e172d21f1e07..b577840cb325 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -12,7 +12,7 @@ import type { SessionCatalogPullRequestSummary, SessionCatalogTranscriptItem, } from "openclaw/plugin-sdk/session-catalog"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumber, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { readClaudeDesktopCustomGroups } from "./claude-desktop-groups.js"; import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_MODEL_REF } from "./cli-constants.js"; import { @@ -222,7 +222,7 @@ function cacheCatalogDiscovery(filePath: string, entry: CatalogDiscoveryCacheEnt setBoundedCache(catalogDiscoveryCache, filePath, entry, MAX_CATALOG_DISCOVERY_CACHE_ENTRIES); } -function optionalString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined { +function readBoundedString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined { if (typeof value !== "string") { return undefined; } @@ -323,15 +323,10 @@ function isCliEntrypoint(value: unknown): value is string { return typeof value === "string" && CLI_ENTRYPOINTS.has(value); } -function timestampMs(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value !== "string") { - return undefined; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; +// Claude's persisted string timestamps are date expressions, including numeric-looking years. +// Numeric fields are already millisecond values, so preserve that distinct mixed-input contract. +function parseClaudeCatalogTimestampMs(value: unknown): number | undefined { + return typeof value === "string" ? asFiniteNumber(Date.parse(value)) : asFiniteNumber(value); } function isWithin(root: string, candidate: string): boolean { @@ -550,7 +545,7 @@ async function readDesktopMetadata(homeDir: string): Promise<{ continue; } const metadata = raw as DesktopSessionMetadata; - const cliSessionId = optionalString(metadata.cliSessionId, 256); + const cliSessionId = readBoundedString(metadata.cliSessionId, 256); if (!cliSessionId) { continue; } @@ -560,7 +555,7 @@ async function readDesktopMetadata(homeDir: string): Promise<{ continue; } if (!archived.has(cliSessionId)) { - const localSessionId = optionalString(metadata.sessionId, 256); + const localSessionId = readBoundedString(metadata.sessionId, 256); const customGroup = localSessionId ? customGroups.get(localSessionId) : undefined; active.set(cliSessionId, customGroup ? { ...metadata, customGroup } : metadata); } @@ -607,7 +602,7 @@ async function readIndexRecords(context: ClaudeSessionScanContext): Promise<{ continue; } const entry = candidate as SessionIndexEntry; - const sessionId = optionalString(entry.sessionId, 256); + const sessionId = readBoundedString(entry.sessionId, 256); if (!sessionId) { continue; } @@ -621,7 +616,7 @@ async function readIndexRecords(context: ClaudeSessionScanContext): Promise<{ if (entry.isSidechain === true) { return undefined; } - const indexedPath = optionalString(entry.fullPath, MAX_STRING_LENGTH); + const indexedPath = readBoundedString(entry.fullPath, MAX_STRING_LENGTH); return await safeSessionFileForScan( context, indexedPath ?? path.join(directory, `${sessionId}.jsonl`), @@ -640,21 +635,23 @@ async function readIndexRecords(context: ClaudeSessionScanContext): Promise<{ if (!safeFile) { continue; } - const createdAt = timestampMs(entry.created); - const updatedAt = timestampMs(entry.modified) ?? timestampMs(entry.fileMtime); - const summary = optionalString(entry.summary, 500); - const firstPrompt = optionalString(entry.firstPrompt, 500); + const createdAt = parseClaudeCatalogTimestampMs(entry.created); + const updatedAt = + parseClaudeCatalogTimestampMs(entry.modified) ?? + parseClaudeCatalogTimestampMs(entry.fileMtime); + const summary = readBoundedString(entry.summary, 500); + const firstPrompt = readBoundedString(entry.firstPrompt, 500); records.set(sessionId, { threadId: sessionId, name: summary ?? firstPrompt ?? null, - cwd: optionalString(entry.projectPath), + cwd: readBoundedString(entry.projectPath), status: "stored", ...(createdAt !== undefined ? { createdAt } : {}), ...(updatedAt !== undefined ? { updatedAt, recencyAt: updatedAt } : {}), source: "claude-cli", modelProvider: "anthropic", - ...(optionalString(entry.gitBranch, 500) - ? { gitBranch: optionalString(entry.gitBranch, 500) } + ...(readBoundedString(entry.gitBranch, 500) + ? { gitBranch: readBoundedString(entry.gitBranch, 500) } : {}), archived: false, filePath: safeFile.filePath, @@ -800,7 +797,7 @@ async function discoverCliRecords( return false; } if (raw.type === "ai-title") { - aiTitle = optionalString(raw.aiTitle, 500) ?? aiTitle; + aiTitle = readBoundedString(raw.aiTitle, 500) ?? aiTitle; return false; } if (typeof raw.entrypoint === "string" && !isCliEntrypoint(raw.entrypoint)) { @@ -820,23 +817,23 @@ async function discoverCliRecords( } const fragments: string[] = []; collectTranscriptText(raw.message.content, fragments); - const firstPrompt = optionalString(fragments[0], 500); - const createdAt = timestampMs(raw.timestamp); + const firstPrompt = readBoundedString(fragments[0], 500); + const createdAt = parseClaudeCatalogTimestampMs(raw.timestamp); records.set(sessionId, { threadId: sessionId, name: aiTitle ?? firstPrompt ?? null, - cwd: optionalString(raw.cwd), + cwd: readBoundedString(raw.cwd), status: "stored", ...(createdAt !== undefined ? { createdAt } : {}), updatedAt: stat.mtimeMs, recencyAt: stat.mtimeMs, source: "claude-cli", modelProvider: "anthropic", - ...(optionalString(raw.version, 256) - ? { cliVersion: optionalString(raw.version, 256) } + ...(readBoundedString(raw.version, 256) + ? { cliVersion: readBoundedString(raw.version, 256) } : {}), - ...(optionalString(raw.gitBranch, 500) - ? { gitBranch: optionalString(raw.gitBranch, 500) } + ...(readBoundedString(raw.gitBranch, 500) + ? { gitBranch: readBoundedString(raw.gitBranch, 500) } : {}), archived: false, filePath, @@ -937,9 +934,9 @@ async function scanClaudeSessions( if (!filePath) { continue; } - const createdAt = timestampMs(metadata.createdAt) ?? existing?.createdAt; - const updatedAt = timestampMs(metadata.lastActivityAt) ?? existing?.updatedAt; - const customGroup = optionalString(metadata.customGroup, 500); + const createdAt = parseClaudeCatalogTimestampMs(metadata.createdAt) ?? existing?.createdAt; + const updatedAt = parseClaudeCatalogTimestampMs(metadata.lastActivityAt) ?? existing?.updatedAt; + const customGroup = readBoundedString(metadata.customGroup, 500); const pullRequest = desktopPullRequestSummary(metadata); records.set(sessionId, { ...(existing ?? { @@ -948,8 +945,9 @@ async function scanClaudeSessions( modelProvider: "anthropic" as const, archived: false as const, }), - name: optionalString(metadata.title, 500) ?? existing?.name ?? null, - cwd: optionalString(metadata.cwd) ?? optionalString(metadata.originCwd) ?? existing?.cwd, + name: readBoundedString(metadata.title, 500) ?? existing?.name ?? null, + cwd: + readBoundedString(metadata.cwd) ?? readBoundedString(metadata.originCwd) ?? existing?.cwd, ...(createdAt !== undefined ? { createdAt } : {}), ...(updatedAt !== undefined ? { updatedAt, recencyAt: updatedAt } : {}), ...(customGroup ? { customGroup } : {}), @@ -1090,7 +1088,7 @@ function readListParams(value: unknown): { throw new ClaudeCatalogParamsError(`unknown Claude session catalog parameter: ${unknown}`); } const cursor = readOptionalCursor(value.cursor, "catalog"); - const searchTerm = optionalString(value.searchTerm, MAX_SEARCH_LENGTH); + const searchTerm = readBoundedString(value.searchTerm, MAX_SEARCH_LENGTH); return { limit: readLimit(value.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT), ...(cursor ? { cursor } : {}), @@ -1140,7 +1138,7 @@ function readTranscriptParams( if (unknown) { throw new ClaudeCatalogParamsError(`unknown Claude session read parameter: ${unknown}`); } - const threadId = optionalString(value.threadId, 256); + const threadId = readBoundedString(value.threadId, 256); if (!threadId || !/^[A-Za-z0-9._:-]+$/.test(threadId)) { throw new ClaudeCatalogParamsError("threadId is invalid"); } @@ -1208,7 +1206,7 @@ export async function readLocalClaudeTranscriptPage( const segment = chunk.subarray(index + 1, right); if (segment.length > 0 || fragments.length > 0) { const line = Buffer.concat([segment, ...fragments.toReversed()]); - const item = parseTranscriptLine(line, optionalString); + const item = parseTranscriptLine(line, readBoundedString); fragments = []; if (item) { found.push({ item, start: position + index + 1 }); @@ -1226,7 +1224,7 @@ export async function readLocalClaudeTranscriptPage( if (position === 0) { if (prefix.length > 0 || fragments.length > 0) { const line = Buffer.concat([prefix, ...fragments.toReversed()]); - const item = parseTranscriptLine(line, optionalString); + const item = parseTranscriptLine(line, readBoundedString); if (item) { found.push({ item, start: 0 }); } @@ -1294,7 +1292,7 @@ function parseCatalogPage(value: unknown): ClaudeSessionCatalogPage { if (!isRecord(candidate)) { throw new Error("Claude node returned an invalid session"); } - const threadId = optionalString(candidate.threadId, 256); + const threadId = readBoundedString(candidate.threadId, 256); const source = candidate.source; if ( !threadId || @@ -1309,7 +1307,7 @@ function parseCatalogPage(value: unknown): ClaudeSessionCatalogPage { if (!(key in candidate)) { return undefined; } - const parsed = optionalString(candidate[key], maxLength); + const parsed = readBoundedString(candidate[key], maxLength); if (!parsed) { throw new Error("Claude node returned an invalid session"); } @@ -1385,7 +1383,7 @@ function parseGatewayQuery(value: unknown): { if (unknown) { throw new ClaudeCatalogParamsError(`unknown Claude session catalog parameter: ${unknown}`); } - const search = optionalString(value.search, MAX_SEARCH_LENGTH); + const search = readBoundedString(value.search, MAX_SEARCH_LENGTH); let hostIds: string[] | undefined; if (value.hostIds !== undefined) { if (!Array.isArray(value.hostIds) || value.hostIds.length > MAX_HOSTS) { @@ -1394,7 +1392,7 @@ function parseGatewayQuery(value: unknown): { hostIds = [ ...new Set( value.hostIds.map((hostId) => { - const normalized = optionalString(hostId, 256); + const normalized = readBoundedString(hostId, 256); if ( !normalized || (normalized !== CLAUDE_LOCAL_SESSION_HOST_ID && !normalized.startsWith("node:")) diff --git a/extensions/azure-speech/speech-provider.ts b/extensions/azure-speech/speech-provider.ts index 793247564874..045a9f9f1345 100644 --- a/extensions/azure-speech/speech-provider.ts +++ b/extensions/azure-speech/speech-provider.ts @@ -12,10 +12,10 @@ import type { } from "openclaw/plugin-sdk/speech-core"; import { asFiniteNumber, - asObject, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { azureSpeechTTS, DEFAULT_AZURE_SPEECH_AUDIO_FORMAT, @@ -68,12 +68,12 @@ function readAzureSpeechEnvEndpoint(): string | undefined { function resolveAzureSpeechConfigRecord( rawConfig: Record, ): Record | undefined { - const providers = asObject(rawConfig.providers); + const providers = asOptionalRecord(rawConfig.providers); return ( - asObject(providers?.["azure-speech"]) ?? - asObject(providers?.azure) ?? - asObject(rawConfig["azure-speech"]) ?? - asObject(rawConfig.azure) + asOptionalRecord(providers?.["azure-speech"]) ?? + asOptionalRecord(providers?.azure) ?? + asOptionalRecord(rawConfig["azure-speech"]) ?? + asOptionalRecord(rawConfig.azure) ); } diff --git a/extensions/azure-speech/tts.ts b/extensions/azure-speech/tts.ts index 91da9e046176..b04bdfb03a31 100644 --- a/extensions/azure-speech/tts.ts +++ b/extensions/azure-speech/tts.ts @@ -8,11 +8,12 @@ import { readProviderJsonResponse, } from "openclaw/plugin-sdk/provider-http"; import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech-core"; -import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core"; +import { trimToUndefined } from "openclaw/plugin-sdk/speech-core"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; /** Default Azure Speech neural voice. */ export const DEFAULT_AZURE_SPEECH_VOICE = "en-US-JennyNeural"; @@ -157,12 +158,12 @@ export async function listAzureSpeechVoices(params: { const voices = await readProviderJsonResponse(response, "azure-speech.voices"); return Array.isArray(voices) ? voices.flatMap((value) => { - const voice = asObject(value); + const voice = asOptionalRecord(value); const id = trimToUndefined(voice?.ShortName); if (!voice || !id || isDeprecatedVoice(voice)) { return []; } - const voiceTag = asObject(voice.VoiceTag); + const voiceTag = asOptionalRecord(voice.VoiceTag); const tailoredScenarios = readAzureVoiceTagStrings(voiceTag?.TailoredScenarios); const personalities = readAzureVoiceTagStrings(voiceTag?.VoicePersonalities); return [ diff --git a/extensions/beam/src/types.ts b/extensions/beam/src/types.ts index 99bb7d5e8130..71196f3da5d1 100644 --- a/extensions/beam/src/types.ts +++ b/extensions/beam/src/types.ts @@ -47,7 +47,7 @@ function hasOnlyKeys(value: Record, allowed: Set): bool return Object.keys(value).every((key) => allowed.has(key)); } -function optionalString(value: unknown, maxLength: number): string | undefined { +function readBoundedString(value: unknown, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; } @@ -96,19 +96,19 @@ export function parseBeamUpload( if (value.version !== 1) { return { ok: false, error: "version must be 1" }; } - const beamId = optionalString(value.beamId, 64); + const beamId = readBoundedString(value.beamId, 64); if (!beamId || !/^[a-f0-9]{32}$/i.test(beamId)) { return { ok: false, error: "beamId must be a 32-character hex id" }; } - const source = optionalString(value.source, 32); + const source = readBoundedString(value.source, 32); if (!source || !/^[a-z0-9._-]+$/i.test(source)) { return { ok: false, error: "source must be a short identifier" }; } - const title = optionalString(value.title, 160); + const title = readBoundedString(value.title, 160); if (!title) { return { ok: false, error: "title must be a non-empty string" }; } - const updatedAt = optionalString(value.updatedAt, 64); + const updatedAt = readBoundedString(value.updatedAt, 64); if (!updatedAt || !isIsoTimestamp(updatedAt)) { return { ok: false, error: "updatedAt must be an ISO timestamp" }; } @@ -118,7 +118,8 @@ export function parseBeamUpload( if (value.truncated !== undefined && typeof value.truncated !== "boolean") { return { ok: false, error: "truncated must be a boolean" }; } - const hookEvent = value.hookEvent === undefined ? undefined : optionalString(value.hookEvent, 64); + const hookEvent = + value.hookEvent === undefined ? undefined : readBoundedString(value.hookEvent, 64); if (value.hookEvent !== undefined && !hookEvent) { return { ok: false, error: "hookEvent must be a short string" }; } @@ -140,7 +141,7 @@ export function parseBeamUpload( ) { return { ok: false, error: "transcript item type is invalid" }; } - const text = optionalString(rawItem.text, BEAM_MAX_ITEM_CHARS); + const text = readBoundedString(rawItem.text, BEAM_MAX_ITEM_CHARS); if (!text) { return { ok: false, diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 621f9ca73cdb..2a447735d987 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -25,7 +25,7 @@ const { } = mocks; const dnsLabelEncoder = new TextEncoder(); -const asString = (value: unknown, fallback: string) => +const stringOrFallback = (value: unknown, fallback: string) => typeof value === "string" && value.trim() ? value : fallback; function expectDnsLabelByteLength(value: string, expected: number) { @@ -81,8 +81,9 @@ function mockCiaoService(params?: { advertise, destroy, on, - getFQDN: () => `${asString(options.type, "service")}.${asString(options.domain, "local")}.`, - getHostname: () => asString(options.hostname, "unknown"), + getFQDN: () => + `${stringOrFallback(options.type, "service")}.${stringOrFallback(options.domain, "local")}.`, + getHostname: () => stringOrFallback(options.hostname, "unknown"), getPort: () => Number(options.port ?? -1), }; Object.defineProperty(service, "serviceState", { @@ -173,7 +174,7 @@ describe("gateway bonjour advertiser", () => { expect(createService).toHaveBeenCalledTimes(1); const [gatewayCall] = createService.mock.calls as Array<[Record]>; expect(gatewayCall?.[0]?.type).toBe("openclaw-gw"); - const gatewayType = asString(gatewayCall?.[0]?.type, ""); + const gatewayType = stringOrFallback(gatewayCall?.[0]?.type, ""); expect(gatewayType.length).toBeLessThanOrEqual(15); expect(gatewayCall?.[0]?.port).toBe(18789); expect(gatewayCall?.[0]?.domain).toBe("local"); diff --git a/extensions/browser/setup-api.ts b/extensions/browser/setup-api.ts index eebd5686c059..a5393f0d93fc 100644 --- a/extensions/browser/setup-api.ts +++ b/extensions/browser/setup-api.ts @@ -4,8 +4,10 @@ */ import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { isRecord } from "./src/record-shared.js"; +import { + isRecord, + normalizeOptionalLowercaseString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; function listContainsBrowser(value: unknown): boolean { return ( diff --git a/extensions/browser/src/browser-proxy-upload.ts b/extensions/browser/src/browser-proxy-upload.ts index 7fa7c1221c4d..34ef1324369a 100644 --- a/extensions/browser/src/browser-proxy-upload.ts +++ b/extensions/browser/src/browser-proxy-upload.ts @@ -7,6 +7,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { assertBrowserProxyFileBytesWithinLimits, assertBrowserProxyFileCountWithinLimit, @@ -16,7 +17,6 @@ import { type BrowserProxyUploadV1, } from "./browser-proxy-envelope.js"; import { DEFAULT_UPLOAD_DIR, resolveExistingUploadPaths } from "./browser/paths.js"; -import { asRecord } from "./record-shared.js"; const logger = createSubsystemLogger("browser"); const BROWSER_PROXY_UPLOAD_ROOT_NAME = ".proxy-uploads"; @@ -73,7 +73,7 @@ export function isBrowserProxyUploadRequest(params: { if (!isFileChooserRequest(params.method, params.path)) { return false; } - const body = asRecord(params.body); + const body = asNullableRecord(params.body); return Boolean(body && Array.isArray(body.paths) && body.paths.length > 0); } @@ -125,7 +125,7 @@ export async function prepareBrowserProxyUploadRequest(params: { if (!isFileChooserRequest(params.method, params.path)) { return { body: params.body }; } - const body = asRecord(params.body); + const body = asNullableRecord(params.body); if (!body) { return { body: params.body }; } @@ -497,7 +497,7 @@ export async function stageBrowserProxyUploadRequest(params: { if (!isFileChooserRequest(params.method, params.path)) { throw new Error("INVALID_REQUEST: browser proxy upload requires the file chooser route"); } - const body = asRecord(params.body); + const body = asNullableRecord(params.body); if (!body || Object.hasOwn(body, "paths")) { throw new Error("INVALID_REQUEST: browser proxy upload body must omit paths"); } diff --git a/extensions/browser/src/browser-tool-session-tabs.ts b/extensions/browser/src/browser-tool-session-tabs.ts index 677402cd395c..1a9567e3347d 100644 --- a/extensions/browser/src/browser-tool-session-tabs.ts +++ b/extensions/browser/src/browser-tool-session-tabs.ts @@ -1,6 +1,7 @@ /** * Session tracking for tabs created through the browser tool. */ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { BrowserTabOwnership } from "./browser/client.types.js"; type SessionTabParams = { @@ -19,10 +20,6 @@ type SessionTabRegistry = { untrackSessionBrowserTab: (params: SessionTabParams) => void; }; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readOpenedTab(result: unknown): { targetId?: string; aliases: string[]; @@ -33,14 +30,14 @@ function readOpenedTab(result: unknown): { return { aliases: [] }; } const opened = result as Record; - const targetId = readString(opened.targetId); + const targetId = normalizeOptionalString(opened.targetId); const aliases = [ targetId, - readString(opened.tabId), - readString(opened.label), - readString(opened.suggestedTargetId), + normalizeOptionalString(opened.tabId), + normalizeOptionalString(opened.label), + normalizeOptionalString(opened.suggestedTargetId), ].filter((alias): alias is string => Boolean(alias)); - const profile = readString(opened.resolvedProfile); + const profile = normalizeOptionalString(opened.resolvedProfile); const rawOwnership = opened.ownership && typeof opened.ownership === "object" ? (opened.ownership as BrowserTabOwnership) diff --git a/extensions/browser/src/browser/chrome-mcp-result.ts b/extensions/browser/src/browser/chrome-mcp-result.ts index 2d18cf3dec0e..5f89a50590cd 100644 --- a/extensions/browser/src/browser/chrome-mcp-result.ts +++ b/extensions/browser/src/browser/chrome-mcp-result.ts @@ -1,12 +1,12 @@ // Parses Chrome MCP tool results and formats redacted tool failures. import path from "node:path"; import { + asNullableRecord, normalizeOptionalString, readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { toErrorObject } from "../infra/errors.js"; import { redactToolPayloadText } from "../logging/redact.js"; -import { asRecord } from "../record-shared.js"; import { redactCdpUrl } from "./cdp.helpers.js"; import { CHROME_CONNECTION_TOOL_ERROR_RE, @@ -28,7 +28,7 @@ function asPages(value: unknown): ChromeMcpStructuredPage[] { } const out: ChromeMcpStructuredPage[] = []; for (const entry of value) { - const record = asRecord(entry); + const record = asNullableRecord(entry); if (!record || typeof record.id !== "number") { continue; } @@ -42,14 +42,14 @@ function asPages(value: unknown): ChromeMcpStructuredPage[] { } function extractStructuredContent(result: ChromeMcpToolResult): Record { - return asRecord(result.structuredContent) ?? {}; + return asNullableRecord(result.structuredContent) ?? {}; } function extractTextContent(result: ChromeMcpToolResult): string[] { const content = Array.isArray(result.content) ? result.content : []; return content .map((entry) => { - const record = asRecord(entry); + const record = asNullableRecord(entry); return record && typeof record.text === "string" ? record.text : ""; }) .filter(Boolean); @@ -80,7 +80,7 @@ export function extractStructuredPages(result: ChromeMcpToolResult): ChromeMcpSt export function extractSnapshot(result: ChromeMcpToolResult): ChromeMcpSnapshotNode { const structured = extractStructuredContent(result); - const snapshot = asRecord(structured.snapshot); + const snapshot = asNullableRecord(structured.snapshot); if (!snapshot) { throw new Error("Chrome MCP snapshot response was missing structured snapshot data."); } diff --git a/extensions/browser/src/browser/chrome-mcp.snapshot.ts b/extensions/browser/src/browser/chrome-mcp.snapshot.ts index 0ae8cc084d6d..72e79b9e1bf3 100644 --- a/extensions/browser/src/browser/chrome-mcp.snapshot.ts +++ b/extensions/browser/src/browser/chrome-mcp.snapshot.ts @@ -5,7 +5,6 @@ * and compact AI snapshots with stable refs and duplicate tracking. */ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { normalizeString } from "../record-shared.js"; import type { SnapshotAriaNode } from "./client.types.js"; import type { RoleRefMap, RoleSnapshotOptions } from "./pw-role-snapshot.js"; import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js"; @@ -20,6 +19,13 @@ export type ChromeMcpSnapshotNode = { children?: ChromeMcpSnapshotNode[]; }; +function normalizeSnapshotString(value: unknown): string | undefined { + if (typeof value === "string") { + return value.trim() || undefined; + } + return typeof value === "number" || typeof value === "boolean" ? String(value) : undefined; +} + function normalizeRole(node: ChromeMcpSnapshotNode): string { const role = normalizeLowercaseStringOrEmpty(node.role); return role || "generic"; @@ -94,14 +100,14 @@ export function flattenChromeMcpSnapshotToAriaNodes( if (out.length >= boundedLimit) { return; } - const ref = normalizeString(node.id); + const ref = normalizeSnapshotString(node.id); if (ref) { out.push({ ref, role: normalizeRole(node), - name: normalizeString(node.name) ?? "", - value: normalizeString(node.value), - description: normalizeString(node.description), + name: normalizeSnapshotString(node.name) ?? "", + value: normalizeSnapshotString(node.value), + description: normalizeSnapshotString(node.description), depth, }); } @@ -131,9 +137,9 @@ export function buildAiSnapshotFromChromeMcpSnapshot(params: { const visit = (node: ChromeMcpSnapshotNode, depth: number) => { const role = normalizeRole(node); - const name = normalizeString(node.name); - const value = normalizeString(node.value); - const description = normalizeString(node.description); + const name = normalizeSnapshotString(node.name); + const value = normalizeSnapshotString(node.value); + const description = normalizeSnapshotString(node.description); const maxDepth = params.options?.maxDepth; if (maxDepth !== undefined && depth > maxDepth) { return; @@ -145,7 +151,7 @@ export function buildAiSnapshotFromChromeMcpSnapshot(params: { if (name) { line += ` "${escapeQuoted(name)}"`; } - const ref = normalizeString(node.id); + const ref = normalizeSnapshotString(node.id); if (ref && shouldCreateRef(role, name)) { const nth = registerRef(tracker, ref, role, name); refs[ref] = nth === undefined ? { role, name } : { role, name, nth }; diff --git a/extensions/browser/src/browser/chrome.graphics.ts b/extensions/browser/src/browser/chrome.graphics.ts index 5f24e38c8753..33ba5fe98df9 100644 --- a/extensions/browser/src/browser/chrome.graphics.ts +++ b/extensions/browser/src/browser/chrome.graphics.ts @@ -1,3 +1,4 @@ +import { asNullableRecord, isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; /** * Managed Chrome graphics diagnostics. * @@ -5,7 +6,6 @@ * exact RunningChrome instance that owns the process. */ import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { asRecord, isRecord } from "../record-shared.js"; import { redactCdpErrorText, withCdpSocket } from "./cdp.helpers.js"; import { getChromeWebSocketUrl, type RunningChrome } from "./chrome.js"; import type { @@ -23,16 +23,16 @@ type ChromeGraphicsProbeOptions = { ssrfPolicy?: SsrFPolicy; }; -function readString(value: unknown): string { +function readChromeString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } -function readNumber(value: unknown): number { +function readChromeNumber(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } function readStringRecord(value: unknown): Record { - const record = asRecord(value); + const record = asNullableRecord(value); if (!record) { return {}; } @@ -49,10 +49,10 @@ function readStringArray(value: unknown): string[] { } function readSize(value: unknown): { width: number; height: number } { - const size = asRecord(value); + const size = asNullableRecord(value); return { - width: readNumber(size?.width), - height: readNumber(size?.height), + width: readChromeNumber(size?.width), + height: readChromeNumber(size?.height), }; } @@ -62,18 +62,18 @@ function readRecordArray(value: unknown): Record[] { function readDevices(value: unknown): BrowserGraphicsDevice[] { return readRecordArray(value).map((device) => ({ - vendorId: readNumber(device.vendorId), - deviceId: readNumber(device.deviceId), - vendor: readString(device.vendorString), - device: readString(device.deviceString), - driverVendor: readString(device.driverVendor), - driverVersion: readString(device.driverVersion), + vendorId: readChromeNumber(device.vendorId), + deviceId: readChromeNumber(device.deviceId), + vendor: readChromeString(device.vendorString), + device: readChromeString(device.deviceString), + driverVendor: readChromeString(device.driverVendor), + driverVersion: readChromeString(device.driverVersion), })); } function readVideoDecoding(value: unknown): BrowserVideoDecodeCapability[] { return readRecordArray(value).map((capability) => ({ - profile: readString(capability.profile), + profile: readChromeString(capability.profile), minResolution: readSize(capability.minResolution), maxResolution: readSize(capability.maxResolution), })); @@ -81,10 +81,10 @@ function readVideoDecoding(value: unknown): BrowserVideoDecodeCapability[] { function readVideoEncoding(value: unknown): BrowserVideoEncodeCapability[] { return readRecordArray(value).map((capability) => ({ - profile: readString(capability.profile), + profile: readChromeString(capability.profile), maxResolution: readSize(capability.maxResolution), - maxFramerateNumerator: readNumber(capability.maxFramerateNumerator), - maxFramerateDenominator: readNumber(capability.maxFramerateDenominator), + maxFramerateNumerator: readChromeNumber(capability.maxFramerateNumerator), + maxFramerateDenominator: readChromeNumber(capability.maxFramerateDenominator), })); } @@ -93,7 +93,7 @@ function firstAttribute( names: readonly string[], ): string | null { for (const name of names) { - const value = readString(attributes[name]); + const value = readChromeString(attributes[name]); if (value) { return value; } @@ -133,8 +133,8 @@ function normalizeChromeGraphicsInfo( value: unknown, observedAt = Date.now(), ): BrowserGraphicsDiagnostics { - const result = asRecord(value); - const gpu = asRecord(result?.gpu); + const result = asNullableRecord(value); + const gpu = asNullableRecord(result?.gpu); if (!gpu) { return { status: "unavailable", diff --git a/extensions/browser/src/browser/chrome.profile-decoration.ts b/extensions/browser/src/browser/chrome.profile-decoration.ts index 47a78f7583f1..ba78c38212f9 100644 --- a/extensions/browser/src/browser/chrome.profile-decoration.ts +++ b/extensions/browser/src/browser/chrome.profile-decoration.ts @@ -7,7 +7,7 @@ import fs from "node:fs"; import path from "node:path"; import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store"; -import { asRecord } from "../record-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_OPENCLAW_BROWSER_COLOR, DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, @@ -20,7 +20,7 @@ function decoratedMarkerPath(userDataDir: string) { } function safeReadJson(filePath: string): Record | null { - return asRecord(loadJsonFile(filePath)); + return asNullableRecord(loadJsonFile(filePath)); } function safeWriteJson(filePath: string, data: Record) { @@ -28,11 +28,14 @@ function safeWriteJson(filePath: string, data: Record) { } function readNestedRecord(root: unknown, key: string): Record | null { - return asRecord(asRecord(root)?.[key]); + return asNullableRecord(asNullableRecord(root)?.[key]); } function readDefaultProfileInfo(localState: unknown): Record | null { - return readNestedRecord(readNestedRecord(asRecord(localState)?.profile, "info_cache"), "Default"); + return readNestedRecord( + readNestedRecord(asNullableRecord(localState)?.profile, "info_cache"), + "Default", + ); } function setDeep(obj: Record, keys: string[], value: unknown) { diff --git a/extensions/browser/src/browser/extension-native-host.ts b/extensions/browser/src/browser/extension-native-host.ts index 4bea06f7134e..f5ec05911c49 100644 --- a/extensions/browser/src/browser/extension-native-host.ts +++ b/extensions/browser/src/browser/extension-native-host.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveStateDir } from "openclaw/plugin-sdk/state-paths"; -import { asRecord } from "../record-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { type BrowserNativeBootstrapResponse, decodeBrowserNativeFrame, @@ -103,10 +103,11 @@ async function validateNativeManifest(params: { throw new Error("launcher is outside the managed root"); } const parsed: unknown = JSON.parse(await fs.readFile(manifestPath, "utf8")); - if (!asRecord(parsed)) { + const manifestRecord = asNullableRecord(parsed); + if (!manifestRecord) { throw new Error("invalid manifest"); } - const manifest = parsed as NativeHostManifest; + const manifest = manifestRecord as NativeHostManifest; const expectedOrigins = validateExpectedOrigins(params.expectedOrigins); const keys = ["name", "description", "path", "type", "allowed_origins"]; if ( diff --git a/extensions/browser/src/browser/extension-native-protocol.ts b/extensions/browser/src/browser/extension-native-protocol.ts index c350f5e597c6..f5dc5e258ed6 100644 --- a/extensions/browser/src/browser/extension-native-protocol.ts +++ b/extensions/browser/src/browser/extension-native-protocol.ts @@ -1,5 +1,5 @@ import os from "node:os"; -import { asRecord } from "../record-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const BROWSER_NATIVE_REQUEST_MAX_BYTES = 4 * 1024; const BROWSER_NATIVE_RESPONSE_MAX_BYTES = 1024 * 1024; @@ -38,7 +38,7 @@ function rootJsonKeys(raw: string): string[] | null { index += 1; } }; - const readString = (): string | null => { + const readJsonString = (): string | null => { if (raw[index] !== '"') { return null; } @@ -103,7 +103,7 @@ function rootJsonKeys(raw: string): string[] | null { if (raw[index] === "}") { return keys; } - const key = readString(); + const key = readJsonString(); if (key === null) { return null; } @@ -149,7 +149,7 @@ function parseBrowserNativeRequest(raw: string): BrowserNativeBootstrapRequest | } catch { return null; } - const record = asRecord(parsed); + const record = asNullableRecord(parsed); return record?.v === 1 && record.op === "bootstrap" && isCanonicalNonce(record.nonce) ? { v: 1, op: "bootstrap", nonce: record.nonce } : null; diff --git a/extensions/browser/src/doctor-browser.ts b/extensions/browser/src/doctor-browser.ts index 423fea348336..5d562bc96f7d 100644 --- a/extensions/browser/src/doctor-browser.ts +++ b/extensions/browser/src/doctor-browser.ts @@ -5,7 +5,10 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asNullableRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { parseBrowserMajorVersion, readBrowserVersion, @@ -20,7 +23,6 @@ import { import { listSystemProfiles } from "./browser/system-profiles.js"; import { movePathToTrash } from "./browser/trash.js"; import type { OpenClawConfig } from "./config/config.js"; -import { asRecord } from "./record-shared.js"; import { formatCliCommand, note } from "./sdk-setup-tools.js"; import { CONFIG_DIR, resolveUserPath } from "./utils.js"; @@ -57,7 +59,7 @@ type BrowserDoctorFilesystemDeps = { }; function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] { - const browser = asRecord(cfg.browser); + const browser = asNullableRecord(cfg.browser); if (!browser) { return []; } @@ -68,13 +70,13 @@ function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] profiles.set("user", { name: "user" }); } - const configuredProfiles = asRecord(browser.profiles); + const configuredProfiles = asNullableRecord(browser.profiles); if (!configuredProfiles) { return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) { - const profile = asRecord(rawProfile); + const profile = asNullableRecord(rawProfile); const driver = normalizeOptionalString(profile?.driver) ?? ""; if (driver === "existing-session") { profiles.set(profileName, { @@ -88,7 +90,7 @@ function collectChromeMcpProfiles(cfg: OpenClawConfig): ExistingSessionProfile[] } function collectManagedProfiles(cfg: OpenClawConfig): ManagedProfile[] { - const browser = asRecord(cfg.browser); + const browser = asNullableRecord(cfg.browser); if (!browser) { return []; } @@ -99,13 +101,13 @@ function collectManagedProfiles(cfg: OpenClawConfig): ManagedProfile[] { profiles.set(defaultProfile, { name: defaultProfile }); } - const configuredProfiles = asRecord(browser.profiles); + const configuredProfiles = asNullableRecord(browser.profiles); if (!configuredProfiles) { return [...profiles.values()].toSorted((a, b) => a.name.localeCompare(b.name)); } for (const [profileName, rawProfile] of Object.entries(configuredProfiles)) { - const profile = asRecord(rawProfile); + const profile = asNullableRecord(rawProfile); const driver = normalizeOptionalString(profile?.driver) ?? "openclaw"; if (driver !== "existing-session") { profiles.set(profileName, { name: profileName }); @@ -134,7 +136,7 @@ function isSameOrChildPath(candidatePath: string, parentPath: string): boolean { } function isLegacyClawdProfileConfigured(cfg: OpenClawConfig, legacyProfileDir: string): boolean { - const browser = asRecord(cfg.browser); + const browser = asNullableRecord(cfg.browser); if (!browser) { return false; } @@ -142,7 +144,7 @@ function isLegacyClawdProfileConfigured(cfg: OpenClawConfig, legacyProfileDir: s return true; } - const configuredProfiles = asRecord(browser.profiles); + const configuredProfiles = asNullableRecord(browser.profiles); if (!configuredProfiles) { return false; } @@ -151,7 +153,7 @@ function isLegacyClawdProfileConfigured(cfg: OpenClawConfig, legacyProfileDir: s } for (const rawProfile of Object.values(configuredProfiles)) { - const profile = asRecord(rawProfile); + const profile = asNullableRecord(rawProfile); const userDataDir = normalizeOptionalString(profile?.userDataDir); if (userDataDir && isSameOrChildPath(resolveUserPath(userDataDir), legacyProfileDir)) { return true; diff --git a/extensions/browser/src/record-shared.ts b/extensions/browser/src/record-shared.ts deleted file mode 100644 index ab28c0f3f015..000000000000 --- a/extensions/browser/src/record-shared.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Small record/string coercion helpers shared by Browser setup and audits. - */ -import { - asNullableRecord, - hasNonEmptyString as sharedHasNonEmptyString, - isRecord, -} from "openclaw/plugin-sdk/string-coerce-runtime"; - -/** Re-export record guards under Browser-local names. */ -export { asNullableRecord as asRecord, isRecord }; - -/** Re-export shared non-empty string predicate. */ -export const hasNonEmptyString = sharedHasNonEmptyString; - -/** Normalizes primitive string/number/boolean values to non-empty strings. */ -export function normalizeString(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed || undefined; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return undefined; -} diff --git a/extensions/browser/src/security-audit.ts b/extensions/browser/src/security-audit.ts index 0b940814f165..27b518f51dac 100644 --- a/extensions/browser/src/security-audit.ts +++ b/extensions/browser/src/security-audit.ts @@ -5,10 +5,12 @@ import type { OpenClawPluginSecurityAuditContext } from "openclaw/plugin-sdk/plu import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools"; import { isPrivateNetworkOptInEnabled, isPrivateIpAddress } from "openclaw/plugin-sdk/ssrf-policy"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + hasNonEmptyString, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { redactCdpUrl, resolveBrowserConfig, resolveProfile } from "./browser/config.js"; import { resolveBrowserControlAuth } from "./browser/control-auth.js"; -import { hasNonEmptyString } from "./record-shared.js"; const BLOCKED_HOSTNAMES = new Set([ "localhost", diff --git a/extensions/clawrouter/provider-catalog.ts b/extensions/clawrouter/provider-catalog.ts index 22a3643388fe..e3fc82e7fa09 100644 --- a/extensions/clawrouter/provider-catalog.ts +++ b/extensions/clawrouter/provider-catalog.ts @@ -8,6 +8,12 @@ import type { ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; +import { + asFiniteNumberInRange, + asOptionalRecord, + asPositiveSafeInteger, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const CLAWROUTER_DEFAULT_BASE_URL = "https://clawrouter.openclaw.ai"; @@ -73,19 +79,9 @@ type RouteMetadata = { upstreamModel?: string; }; -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readStringArray(value: unknown): string[] { return Array.isArray(value) - ? value.map(readString).filter((entry): entry is string => Boolean(entry)) + ? value.map(normalizeOptionalString).filter((entry): entry is string => Boolean(entry)) : []; } @@ -102,16 +98,8 @@ export function normalizeClawRouterReasoningEfforts( return efforts.length > 0 ? efforts : undefined; } -function readNonNegativeNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - -function readPositiveSafeInteger(value: unknown): number | undefined { - return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : undefined; -} - function readCatalogRows(body: unknown): readonly unknown[] { - const providers = readRecord(body)?.providers; + const providers = asOptionalRecord(body)?.providers; if (!Array.isArray(providers)) { throw new Error("ClawRouter catalog response must contain providers[]"); } @@ -119,9 +107,9 @@ function readCatalogRows(body: unknown): readonly unknown[] { } function parseCatalogRoute(value: unknown): CatalogRoute | undefined { - const row = readRecord(value); - const path = readString(row?.path); - const requestFormat = readString(row?.requestFormat); + const row = asOptionalRecord(value); + const path = normalizeOptionalString(row?.path); + const requestFormat = normalizeOptionalString(row?.requestFormat); if (!path || !requestFormat) { return undefined; } @@ -133,25 +121,35 @@ function parseCatalogRoute(value: unknown): CatalogRoute | undefined { } function parseCatalogPricing(value: unknown): CatalogPricing | undefined { - const row = readRecord(value); + const row = asOptionalRecord(value); if (!row) { return undefined; } return { - inputMicrosPerMillion: readNonNegativeNumber(row.inputMicrosPerMillion), - outputMicrosPerMillion: readNonNegativeNumber(row.outputMicrosPerMillion), - cachedInputMicrosPerMillion: readNonNegativeNumber(row.cachedInputMicrosPerMillion), - cacheWrite5mInputMicrosPerMillion: readNonNegativeNumber(row.cacheWrite5mInputMicrosPerMillion), - cacheWrite1hInputMicrosPerMillion: readNonNegativeNumber(row.cacheWrite1hInputMicrosPerMillion), - maxInputTokens: readPositiveSafeInteger(row.maxInputTokens), - defaultMaxOutputTokens: readPositiveSafeInteger(row.defaultMaxOutputTokens), + inputMicrosPerMillion: asFiniteNumberInRange(row.inputMicrosPerMillion, { min: 0 }), + outputMicrosPerMillion: asFiniteNumberInRange(row.outputMicrosPerMillion, { min: 0 }), + cachedInputMicrosPerMillion: asFiniteNumberInRange(row.cachedInputMicrosPerMillion, { min: 0 }), + cacheWrite5mInputMicrosPerMillion: asFiniteNumberInRange( + row.cacheWrite5mInputMicrosPerMillion, + { + min: 0, + }, + ), + cacheWrite1hInputMicrosPerMillion: asFiniteNumberInRange( + row.cacheWrite1hInputMicrosPerMillion, + { + min: 0, + }, + ), + maxInputTokens: asPositiveSafeInteger(row.maxInputTokens), + defaultMaxOutputTokens: asPositiveSafeInteger(row.defaultMaxOutputTokens), }; } function parseCatalogModel(value: unknown): CatalogModel | undefined { - const row = readRecord(value); - const id = readString(row?.id); - const upstream = readString(row?.upstream); + const row = asOptionalRecord(value); + const id = normalizeOptionalString(row?.id); + const upstream = normalizeOptionalString(row?.upstream); if (!id || !upstream) { return undefined; } @@ -165,15 +163,15 @@ function parseCatalogModel(value: unknown): CatalogModel | undefined { } function parseCatalogProvider(value: unknown): CatalogProvider | undefined { - const row = readRecord(value); - const id = readString(row?.id); - const nativeBaseUrl = readString(row?.nativeBaseUrl); + const row = asOptionalRecord(value); + const id = normalizeOptionalString(row?.id); + const nativeBaseUrl = normalizeOptionalString(row?.nativeBaseUrl); if (!id || !nativeBaseUrl || !nativeBaseUrl.startsWith("/v1/native/")) { return undefined; } return { id, - displayName: readString(row?.displayName) ?? id, + displayName: normalizeOptionalString(row?.displayName) ?? id, openaiCompatible: row?.openaiCompatible === true, nativeBaseUrl, routes: Array.isArray(row?.routes) @@ -377,9 +375,9 @@ export async function buildClawRouterProviderConfig(params: { } function readRouteMetadata(params: ProviderRuntimeModel["params"]): RouteMetadata | undefined { - const row = readRecord(params?.[ROUTE_METADATA_KEY]); - const baseUrl = readString(row?.baseUrl); - const api = readString(row?.api); + const row = asOptionalRecord(params?.[ROUTE_METADATA_KEY]); + const baseUrl = normalizeOptionalString(row?.baseUrl); + const api = normalizeOptionalString(row?.api); if ( !baseUrl || (api !== "openai-responses" && @@ -389,7 +387,7 @@ function readRouteMetadata(params: ProviderRuntimeModel["params"]): RouteMetadat ) { return undefined; } - const upstreamModel = readString(row?.upstreamModel); + const upstreamModel = normalizeOptionalString(row?.upstreamModel); return { api, baseUrl, diff --git a/extensions/codex/doctor-contract-api.ts b/extensions/codex/doctor-contract-api.ts index 9792d7bd3cbc..a917737cbfeb 100644 --- a/extensions/codex/doctor-contract-api.ts +++ b/extensions/codex/doctor-contract-api.ts @@ -2,6 +2,7 @@ * Doctor contract hooks for Codex plugin config and state migrations. */ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; type LegacyConfigRule = { path: string[]; @@ -9,32 +10,26 @@ type LegacyConfigRule = { match: (value: unknown) => boolean; }; -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function hasRetiredDynamicToolsProfile(value: unknown): boolean { - return Object.hasOwn(asRecord(value) ?? {}, "codexDynamicToolsProfile"); + return Object.hasOwn(asNullableRecord(value) ?? {}, "codexDynamicToolsProfile"); } function hasLegacyPluginDestructivePolicy(value: unknown): boolean { - const codexPlugins = asRecord(value); + const codexPlugins = asNullableRecord(value); if (!codexPlugins) { return false; } if (codexPlugins.allow_destructive_actions === "on-request") { return true; } - const plugins = asRecord(codexPlugins.plugins); + const plugins = asNullableRecord(codexPlugins.plugins); return Object.values(plugins ?? {}).some( - (plugin) => asRecord(plugin)?.allow_destructive_actions === "on-request", + (plugin) => asNullableRecord(plugin)?.allow_destructive_actions === "on-request", ); } function hasRetiredOnFailureApprovalPolicy(value: unknown): boolean { - return asRecord(value)?.approvalPolicy === "on-failure"; + return asNullableRecord(value)?.approvalPolicy === "on-failure"; } /** Legacy Codex config keys that doctor should report or repair. */ @@ -66,10 +61,10 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): config: OpenClawConfig; changes: string[]; } { - const rawEntry = asRecord(cfg.plugins?.entries?.codex); - const rawPluginConfig = asRecord(rawEntry?.config); - const rawCodexPlugins = asRecord(rawPluginConfig?.codexPlugins); - const rawAppServer = asRecord(rawPluginConfig?.appServer); + const rawEntry = asNullableRecord(cfg.plugins?.entries?.codex); + const rawPluginConfig = asNullableRecord(rawEntry?.config); + const rawCodexPlugins = asNullableRecord(rawPluginConfig?.codexPlugins); + const rawAppServer = asNullableRecord(rawPluginConfig?.appServer); const shouldRemoveDynamicToolsProfile = rawPluginConfig !== null && hasRetiredDynamicToolsProfile(rawPluginConfig); const shouldRewriteDestructivePolicy = hasLegacyPluginDestructivePolicy(rawCodexPlugins); @@ -86,10 +81,10 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): const nextConfig = structuredClone(cfg) as OpenClawConfig & { plugins?: Record; }; - const nextPlugins = asRecord(nextConfig.plugins); - const nextEntries = asRecord(nextPlugins?.entries); - const nextEntry = asRecord(nextEntries?.codex); - const nextPluginConfig = asRecord(nextEntry?.config); + const nextPlugins = asNullableRecord(nextConfig.plugins); + const nextEntries = asNullableRecord(nextPlugins?.entries); + const nextEntry = asNullableRecord(nextEntries?.codex); + const nextPluginConfig = asNullableRecord(nextEntry?.config); if (!nextPluginConfig) { return { config: cfg, changes: [] }; } @@ -103,13 +98,13 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): } if (shouldRewriteDestructivePolicy) { - const nextCodexPlugins = asRecord(nextPluginConfig.codexPlugins); + const nextCodexPlugins = asNullableRecord(nextPluginConfig.codexPlugins); if (nextCodexPlugins?.allow_destructive_actions === "on-request") { nextCodexPlugins.allow_destructive_actions = "auto"; } - const nextPluginPolicies = asRecord(nextCodexPlugins?.plugins); + const nextPluginPolicies = asNullableRecord(nextCodexPlugins?.plugins); for (const plugin of Object.values(nextPluginPolicies ?? {})) { - const nextPlugin = asRecord(plugin); + const nextPlugin = asNullableRecord(plugin); if (nextPlugin?.allow_destructive_actions === "on-request") { nextPlugin.allow_destructive_actions = "auto"; } @@ -120,7 +115,7 @@ export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): } if (shouldRewriteApprovalPolicy) { - const nextAppServer = asRecord(nextPluginConfig.appServer); + const nextAppServer = asNullableRecord(nextPluginConfig.appServer); if (nextAppServer?.approvalPolicy === "on-failure") { nextAppServer.approvalPolicy = "on-request"; } diff --git a/extensions/codex/src/app-server/approval-bridge.ts b/extensions/codex/src/app-server/approval-bridge.ts index b653193e680d..b3cdaa38947a 100644 --- a/extensions/codex/src/app-server/approval-bridge.ts +++ b/extensions/codex/src/app-server/approval-bridge.ts @@ -13,7 +13,10 @@ import { type NativeHookRelayProcessResponse, type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeTrimmedStringList, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { formatCodexDisplayText } from "../command-formatters.js"; import { resolveCodexToolAbortTerminalReason } from "./dynamic-tool-execution.js"; @@ -1271,11 +1274,6 @@ function readStringPreview( return value === undefined ? undefined : previewSource(value); } -function readString(record: JsonObject | undefined, key: string): string | undefined { - const value = record?.[key]; - return typeof value === "string" ? value : undefined; -} - function previewSource(value: string): ApprovalPreviewSource { return { value: sliceUtf16Safe(value, 0, APPROVAL_PREVIEW_SCAN_MAX_LENGTH), diff --git a/extensions/codex/src/app-server/attempt-notifications.ts b/extensions/codex/src/app-server/attempt-notifications.ts index 0da610fa689e..3d4a3b0798ef 100644 --- a/extensions/codex/src/app-server/attempt-notifications.ts +++ b/extensions/codex/src/app-server/attempt-notifications.ts @@ -1,3 +1,4 @@ +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; /** * Predicates and readers for Codex app-server notification envelopes. */ @@ -390,11 +391,6 @@ function extractRawResponseItemText(item: JsonObject): string { .join(""); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - /** Reads a typed Codex item from notification params when id/type are present. */ export function readCodexNotificationItem( params: JsonValue | undefined, diff --git a/extensions/codex/src/app-server/bounded-turn.ts b/extensions/codex/src/app-server/bounded-turn.ts index 027729d358ac..069bc7084c25 100644 --- a/extensions/codex/src/app-server/bounded-turn.ts +++ b/extensions/codex/src/app-server/bounded-turn.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import { CODEX_APP_SERVER_INTERRUPT_TIMEOUT_MS, @@ -671,8 +672,3 @@ function collectAssistantTextFromItems(items: CodexThreadItem[] | undefined): st .join("\n\n") .trim(); } - -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} diff --git a/extensions/codex/src/app-server/effective-mcp-catalog.ts b/extensions/codex/src/app-server/effective-mcp-catalog.ts index 690035aa7f50..d2991d05af7b 100644 --- a/extensions/codex/src/app-server/effective-mcp-catalog.ts +++ b/extensions/codex/src/app-server/effective-mcp-catalog.ts @@ -3,6 +3,10 @@ import { assignMcpCatalogSafeServerNames, type McpToolCatalog, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexAppServerClient } from "./client.js"; import type { CodexMcpServerStatus } from "./protocol.js"; import { sessionBindingIdentity, type CodexAppServerBindingStore } from "./session-binding.js"; @@ -12,16 +16,6 @@ const MCP_STATUS_PAGE_SIZE = 100; const MCP_STATUS_MAX_PAGES = 100; type AgentHarnessMcpCatalogParams = Parameters>[0]; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function catalogTool(params: { serverName: string; safeServerName: string; @@ -29,15 +23,16 @@ function catalogTool(params: { raw?: unknown; deniedBySession?: true; }): McpToolCatalog["tools"][number] { - const raw = asRecord(params.raw); - const description = readString(raw?.description); + const raw = asOptionalRecord(params.raw); + const description = normalizeOptionalString(raw?.description); + const title = normalizeOptionalString(raw?.title); return { serverName: params.serverName, safeServerName: params.safeServerName, toolName: params.toolName, - ...(readString(raw?.title) ? { title: readString(raw?.title) } : {}), + ...(title ? { title } : {}), ...(description ? { description } : {}), - inputSchema: (asRecord(raw?.inputSchema) ?? { type: "object" }) as never, + inputSchema: (asOptionalRecord(raw?.inputSchema) ?? { type: "object" }) as never, fallbackDescription: description ?? params.toolName, ...(params.deniedBySession ? { deniedBySession: true } : {}), }; diff --git a/extensions/codex/src/app-server/elicitation-bridge.ts b/extensions/codex/src/app-server/elicitation-bridge.ts index c8dc1531a30b..b90d7e22c684 100644 --- a/extensions/codex/src/app-server/elicitation-bridge.ts +++ b/extensions/codex/src/app-server/elicitation-bridge.ts @@ -80,7 +80,7 @@ export async function handleCodexAppServerElicitationRequest(params: { signal?: AbortSignal; }): Promise { const requestParams = isJsonObject(params.requestParams) ? params.requestParams : undefined; - if (!requestParams || readString(requestParams, "threadId") !== params.threadId) { + if (!requestParams || readNonBlankStringField(requestParams, "threadId") !== params.threadId) { return undefined; } const requestTurnId = requestParams.turnId; @@ -161,7 +161,7 @@ function resolvePluginElicitation(params: { return uniquePluginMatch(entry ? [entry] : [], "connector_id"); } - const serverName = readString(requestParams, "serverName"); + const serverName = readNonBlankStringField(requestParams, "serverName"); if (serverName && context) { const matches = entries.filter((entry) => entry.mcpServerNames.includes(serverName)); if (matches.length > 0) { @@ -188,9 +188,10 @@ function resolvePluginElicitation(params: { function isCodexConnectorApprovalElicitation(requestParams: JsonObject, meta: JsonObject): boolean { return ( - readString(requestParams, "serverName") === CODEX_APPS_SERVER_NAME && - readString(meta, MCP_TOOL_APPROVAL_KIND_KEY) === MCP_TOOL_APPROVAL_KIND && - readString(meta, MCP_TOOL_APPROVAL_SOURCE_KEY) === MCP_TOOL_APPROVAL_CONNECTOR_SOURCE + readNonBlankStringField(requestParams, "serverName") === CODEX_APPS_SERVER_NAME && + readNonBlankStringField(meta, MCP_TOOL_APPROVAL_KIND_KEY) === MCP_TOOL_APPROVAL_KIND && + readNonBlankStringField(meta, MCP_TOOL_APPROVAL_SOURCE_KEY) === + MCP_TOOL_APPROVAL_CONNECTOR_SOURCE ); } @@ -247,7 +248,7 @@ function hasDisplayNameOnlyPluginMatch( meta: JsonObject, entries: CodexAppPolicyContextEntry[], ): boolean { - const connectorName = readString(meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY); + const connectorName = readNonBlankStringField(meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY); if (!connectorName) { return false; } @@ -340,14 +341,14 @@ function readPluginApprovalElicitation( requestParams: JsonObject, ): BridgeableApprovalElicitation | undefined { if ( - readString(requestParams, "mode") !== "form" || + readNonBlankStringField(requestParams, "mode") !== "form" || !isJsonObject(requestParams.requestedSchema) ) { return undefined; } const requestedSchema = requestParams.requestedSchema; if ( - readString(requestedSchema, "type") !== "object" || + readNonBlankStringField(requestedSchema, "type") !== "object" || !isJsonObject(requestedSchema.properties) ) { return undefined; @@ -355,9 +356,10 @@ function readPluginApprovalElicitation( const meta = isJsonObject(requestParams["_meta"]) ? requestParams["_meta"] : {}; const title = - sanitizeDisplayText(readString(requestParams, "message") ?? "") || "Codex plugin approval"; + sanitizeDisplayText(readNonBlankStringField(requestParams, "message") ?? "") || + "Codex plugin approval"; const descriptionMeta: JsonObject = { ...meta }; - if (!readString(descriptionMeta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY)) { + if (!readNonBlankStringField(descriptionMeta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY)) { descriptionMeta[MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY] = appPolicyDisplayName(entry); } return { @@ -366,7 +368,7 @@ function readPluginApprovalElicitation( title, meta: descriptionMeta, requestedSchema, - serverName: sanitizeOptionalDisplayText(readString(requestParams, "serverName")), + serverName: sanitizeOptionalDisplayText(readNonBlankStringField(requestParams, "serverName")), }), requestedSchema, meta, @@ -409,8 +411,8 @@ function declineElicitationResponse(): JsonValue { function logPluginElicitationDecline(reason: string, requestParams: JsonObject | undefined): void { embeddedAgentLog.debug("codex plugin elicitation declined", { reason, - serverName: readString(requestParams, "serverName"), - mode: readString(requestParams, "mode"), + serverName: readNonBlankStringField(requestParams, "serverName"), + mode: readNonBlankStringField(requestParams, "mode"), }); } @@ -419,7 +421,7 @@ function readBridgeableApprovalElicitation( ): BridgeableApprovalElicitation | undefined { if ( !requestParams || - readString(requestParams, "mode") !== "form" || + readNonBlankStringField(requestParams, "mode") !== "form" || !isJsonObject(requestParams["_meta"]) || requestParams["_meta"][MCP_TOOL_APPROVAL_KIND_KEY] !== MCP_TOOL_APPROVAL_KIND || !isJsonObject(requestParams.requestedSchema) @@ -429,21 +431,22 @@ function readBridgeableApprovalElicitation( const requestedSchema = requestParams.requestedSchema; if ( - readString(requestedSchema, "type") !== "object" || + readNonBlankStringField(requestedSchema, "type") !== "object" || !isJsonObject(requestedSchema.properties) ) { return undefined; } const title = - sanitizeDisplayText(readString(requestParams, "message") ?? "") || "Codex MCP tool approval"; + sanitizeDisplayText(readNonBlankStringField(requestParams, "message") ?? "") || + "Codex MCP tool approval"; return { title, description: buildApprovalDescription({ title, meta: requestParams["_meta"], requestedSchema, - serverName: sanitizeOptionalDisplayText(readString(requestParams, "serverName")), + serverName: sanitizeOptionalDisplayText(readNonBlankStringField(requestParams, "serverName")), }), requestedSchema, meta: requestParams["_meta"], @@ -454,12 +457,12 @@ function readComputerUseApprovalElicitation( requestParams: JsonObject | undefined, expectedServerName: string | undefined, ): BridgeableApprovalElicitation | undefined { - const serverName = readString(requestParams, "serverName"); + const serverName = readNonBlankStringField(requestParams, "serverName"); if ( !serverName || !expectedServerName || serverName !== expectedServerName || - readString(requestParams, "mode") !== "form" + readNonBlankStringField(requestParams, "mode") !== "form" ) { return undefined; } @@ -468,7 +471,7 @@ function readComputerUseApprovalElicitation( ? requestParams.requestedSchema : EMPTY_OBJECT_SCHEMA; if ( - readString(requestedSchema, "type") !== "object" || + readNonBlankStringField(requestedSchema, "type") !== "object" || !isJsonObject(requestedSchema.properties) ) { return undefined; @@ -476,7 +479,8 @@ function readComputerUseApprovalElicitation( const meta = isJsonObject(requestParams?.["_meta"]) ? requestParams["_meta"] : {}; const title = - sanitizeDisplayText(readString(requestParams, "message") ?? "") || COMPUTER_USE_APPROVAL_TITLE; + sanitizeDisplayText(readNonBlankStringField(requestParams, "message") ?? "") || + COMPUTER_USE_APPROVAL_TITLE; return { title, description: buildApprovalDescription({ @@ -497,13 +501,13 @@ function buildApprovalDescription(params: { serverName: string | undefined; }): string { const connectorName = sanitizeOptionalDisplayText( - readString(params.meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY), + readNonBlankStringField(params.meta, MCP_TOOL_APPROVAL_CONNECTOR_NAME_KEY), ); const toolTitle = sanitizeOptionalDisplayText( - readString(params.meta, MCP_TOOL_APPROVAL_TOOL_TITLE_KEY), + readNonBlankStringField(params.meta, MCP_TOOL_APPROVAL_TOOL_TITLE_KEY), ); const toolDescription = sanitizeOptionalDisplayText( - readString(params.meta, MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY), + readNonBlankStringField(params.meta, MCP_TOOL_APPROVAL_TOOL_DESCRIPTION_KEY), ); const summaryLines = [ connectorName && `App: ${connectorName}`, @@ -532,10 +536,12 @@ function readPropertyDescriptionLines(requestedSchema: JsonObject): string[] { return undefined; } const propTitle = - sanitizeDisplayText(readString(schema, "title") ?? "") || + sanitizeDisplayText(readNonBlankStringField(schema, "title") ?? "") || sanitizeDisplayText(name) || "field"; - const description = sanitizeOptionalDisplayText(readString(schema, "description")); + const description = sanitizeOptionalDisplayText( + readNonBlankStringField(schema, "description"), + ); return description ? `- ${propTitle}: ${description}` : `- ${propTitle}`; }) .filter((line): line is string => Boolean(line)); @@ -554,8 +560,8 @@ function readDisplayParamLines(meta: JsonObject): string[] { return undefined; } const name = - sanitizeOptionalDisplayText(readString(param, "display_name")) ?? - sanitizeOptionalDisplayText(readString(param, "name")); + sanitizeOptionalDisplayText(readNonBlankStringField(param, "display_name")) ?? + sanitizeOptionalDisplayText(readNonBlankStringField(param, "name")); if (!name) { return undefined; } @@ -764,7 +770,7 @@ function readApprovalFieldValue( if (!isApprovalField(property)) { return undefined; } - const type = readString(property.schema, "type"); + const type = readNonBlankStringField(property.schema, "type"); if (type === "boolean") { return true; } @@ -831,8 +837,8 @@ function isPersistField(property: ApprovalPropertyContext): boolean { function propertyText(property: ApprovalPropertyContext): string { return [ property.name, - readString(property.schema, "title"), - readString(property.schema, "description"), + readNonBlankStringField(property.schema, "title"), + readNonBlankStringField(property.schema, "description"), ] .filter(Boolean) .join(" "); @@ -902,11 +908,11 @@ function readEnumOptions(schema: JsonObject): Array<{ value: string; label: stri return schema.oneOf .map((entry) => { const option = isJsonObject(entry) ? entry : undefined; - const value = readString(option, "const"); + const value = readNonBlankStringField(option, "const"); if (!value) { return undefined; } - return { value, label: readString(option, "title") ?? value }; + return { value, label: readNonBlankStringField(option, "title") ?? value }; }) .filter((entry): entry is { value: string; label: string } => Boolean(entry)); } @@ -925,14 +931,14 @@ function isSessionApprovalOption(option: { value: string; label: string }): bool ); } -function readString(record: JsonObject | undefined, key: string): string | undefined { +function readNonBlankStringField(record: JsonObject | undefined, key: string): string | undefined { const value = record?.[key]; return typeof value === "string" && value.trim() ? value : undefined; } function readFirstString(record: JsonObject | undefined, keys: string[]): string | undefined { for (const key of keys) { - const value = readString(record, key); + const value = readNonBlankStringField(record, key); if (value) { return value; } diff --git a/extensions/codex/src/app-server/event-projector-assistant.ts b/extensions/codex/src/app-server/event-projector-assistant.ts index 354a08ae00e7..031f451c7ccd 100644 --- a/extensions/codex/src/app-server/event-projector-assistant.ts +++ b/extensions/codex/src/app-server/event-projector-assistant.ts @@ -1,5 +1,6 @@ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createAssistantCommentaryMessage as buildAssistantCommentaryMessage, createAssistantMessage as buildAssistantMessage, @@ -7,7 +8,7 @@ import { type AssistantMessageOptions, } from "./event-projector-assistant-message.js"; import { shouldClearTerminalPresentationForNativeItem } from "./event-projector-items.js"; -import { extractRawAssistantText, readItemString, readString } from "./event-projector-values.js"; +import { extractRawAssistantText, readItemString } from "./event-projector-values.js"; import type { CodexThreadItem, JsonObject } from "./protocol.js"; type AgentEvent = Parameters>[0]; diff --git a/extensions/codex/src/app-server/event-projector-events.ts b/extensions/codex/src/app-server/event-projector-events.ts index 13d03be0b50d..72bd1b87ab98 100644 --- a/extensions/codex/src/app-server/event-projector-events.ts +++ b/extensions/codex/src/app-server/event-projector-events.ts @@ -1,4 +1,8 @@ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + asFiniteNumber, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isNonSuccessItemStatus, itemKind, @@ -19,12 +23,7 @@ import { shouldEmitTranscriptToolProgress, } from "./event-projector-tool-progress.js"; import { CodexToolTranscriptProjection } from "./event-projector-tool-transcript.js"; -import { - readHookOutputEntries, - readNullableString, - readNumber, - readString, -} from "./event-projector-values.js"; +import { readHookOutputEntries, readNullableString } from "./event-projector-values.js"; import { isJsonObject, type CodexThreadItem, type JsonObject } from "./protocol.js"; type AgentEvent = Parameters>[0]; @@ -78,7 +77,7 @@ export class CodexEventProjection { if (!run) { return; } - const durationMs = readNumber(run, "durationMs"); + const durationMs = asFiniteNumber(run.durationMs); const entries = readHookOutputEntries(run.entries); const hookTurnId = readNullableString(params, "turnId"); this.emitAgentEvent({ diff --git a/extensions/codex/src/app-server/event-projector-media.ts b/extensions/codex/src/app-server/event-projector-media.ts index ae9b77c8a508..e6d1b428507d 100644 --- a/extensions/codex/src/app-server/event-projector-media.ts +++ b/extensions/codex/src/app-server/event-projector-media.ts @@ -5,7 +5,8 @@ import { import { generatedImageAssetFromBase64 } from "openclaw/plugin-sdk/image-generation"; import { resolveGeneratedMediaMaxBytes } from "openclaw/plugin-sdk/media-generation-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; -import { readItemString, readString } from "./event-projector-values.js"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readItemString } from "./event-projector-values.js"; import type { CodexThreadItem, JsonObject } from "./protocol.js"; import type { CodexRemoteWorkspaceFileReader } from "./remote-workspace-media.js"; diff --git a/extensions/codex/src/app-server/event-projector-native-tool-lifecycle.ts b/extensions/codex/src/app-server/event-projector-native-tool-lifecycle.ts index fcfe152715f6..ba86225b6b21 100644 --- a/extensions/codex/src/app-server/event-projector-native-tool-lifecycle.ts +++ b/extensions/codex/src/app-server/event-projector-native-tool-lifecycle.ts @@ -4,6 +4,7 @@ import type { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime"; import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexToolAbortTerminalReason } from "./dynamic-tool-execution.js"; import { auditNativeToolName, @@ -13,7 +14,7 @@ import { type CodexNativeToolAuditStatus, type CodexNativeToolUnfinishedStatus, } from "./event-projector-items.js"; -import { readItem, readString } from "./event-projector-values.js"; +import { readItem } from "./event-projector-values.js"; import { emitCodexNativePreToolUseFailureDiagnostic, type CodexNativePreToolUseFailure, diff --git a/extensions/codex/src/app-server/event-projector-reasoning.ts b/extensions/codex/src/app-server/event-projector-reasoning.ts index 0d79cb1963d0..75cf1e0402af 100644 --- a/extensions/codex/src/app-server/event-projector-reasoning.ts +++ b/extensions/codex/src/app-server/event-projector-reasoning.ts @@ -1,9 +1,9 @@ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { AgentPlanStep, AgentPlanStepStatus } from "openclaw/plugin-sdk/channel-outbound"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { readNonNegativeInteger, readNullableString, - readString, splitPlanText, } from "./event-projector-values.js"; import type { CodexThreadItem, JsonObject } from "./protocol.js"; diff --git a/extensions/codex/src/app-server/event-projector-tool-output.ts b/extensions/codex/src/app-server/event-projector-tool-output.ts index f1c265ce0e70..9f9117224b82 100644 --- a/extensions/codex/src/app-server/event-projector-tool-output.ts +++ b/extensions/codex/src/app-server/event-projector-tool-output.ts @@ -2,8 +2,8 @@ import { formatToolAggregate, formatToolProgressOutput, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; -import { readString } from "./event-projector-values.js"; import { isJsonObject, type CodexThreadItem } from "./protocol.js"; export const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20; diff --git a/extensions/codex/src/app-server/event-projector-tool-progress.ts b/extensions/codex/src/app-server/event-projector-tool-progress.ts index 6db4b62e5bdd..a7584c74347a 100644 --- a/extensions/codex/src/app-server/event-projector-tool-progress.ts +++ b/extensions/codex/src/app-server/event-projector-tool-progress.ts @@ -4,6 +4,7 @@ import { type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, type ToolProgressDetailMode, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; import { @@ -35,7 +36,6 @@ import { toolOutputRawEchoSignature, truncateToolTranscriptText, } from "./event-projector-tool-output.js"; -import { readString } from "./event-projector-values.js"; import type { CodexDynamicToolCallOutputContentItem, CodexThreadItem, diff --git a/extensions/codex/src/app-server/event-projector-usage.ts b/extensions/codex/src/app-server/event-projector-usage.ts index d3b67a878db9..5caa688b0bb3 100644 --- a/extensions/codex/src/app-server/event-projector-usage.ts +++ b/extensions/codex/src/app-server/event-projector-usage.ts @@ -1,5 +1,9 @@ import { normalizeUsage } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { readNonNegativeInteger, readNumber, readString } from "./event-projector-values.js"; +import { + asFiniteNumber, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readNonNegativeInteger } from "./event-projector-values.js"; import { isJsonObject, type JsonObject } from "./protocol.js"; function readTokenCount(record: JsonObject, key: string): number | undefined { @@ -80,7 +84,10 @@ function normalizeCodexTokenUsageBreakdown( ): ReturnType { // v2 TokenUsageBreakdown. inputTokens includes cached input; OpenClaw usage // tracks uncached input, cache reads, and cache writes separately. - const readCount = source === "response" ? readTokenCount : readNumber; + const readCount = + source === "response" + ? readTokenCount + : (value: JsonObject, key: string) => asFiniteNumber(value[key]); const totalTokens = readCount(record, "totalTokens"); const inputTokens = readCount(record, "inputTokens"); const cacheRead = readCount(record, "cachedInputTokens"); diff --git a/extensions/codex/src/app-server/event-projector-values.ts b/extensions/codex/src/app-server/event-projector-values.ts index 1e620831f15e..9c0bbe04c11a 100644 --- a/extensions/codex/src/app-server/event-projector-values.ts +++ b/extensions/codex/src/app-server/event-projector-values.ts @@ -1,10 +1,6 @@ +import { asFiniteNumber, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isJsonObject, type CodexThreadItem, type JsonObject, type JsonValue } from "./protocol.js"; -export function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - export function normalizeNonEmptyString(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -39,19 +35,14 @@ export function readNullableString(record: JsonObject, key: string): string | nu return typeof value === "string" ? value : undefined; } -export function readNumber(record: JsonObject, key: string): number | undefined { - const value = record[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - export function readNonNegativeInteger(record: JsonObject, key: string): number | undefined { - const value = readNumber(record, key); + const value = asFiniteNumber(record[key]); return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined; } export function readCodexErrorNotificationMessage(record: JsonObject): string | undefined { const error = record.error; - return isJsonObject(error) ? readString(error, "message") : undefined; + return isJsonObject(error) ? readStringField(error, "message") : undefined; } export function readHookOutputEntries( @@ -64,11 +55,11 @@ export function readHookOutputEntries( if (!isJsonObject(entry)) { return []; } - const text = readString(entry, "text"); + const text = readStringField(entry, "text"); if (!text) { return []; } - const kind = readString(entry, "kind"); + const kind = readStringField(entry, "kind"); return [{ ...(kind ? { kind } : {}), text }]; }); } @@ -86,11 +77,11 @@ export function extractRawAssistantText(item: JsonObject): string | undefined { if (!isJsonObject(entry)) { return []; } - const type = readString(entry, "type"); + const type = readStringField(entry, "type"); if (type !== "output_text" && type !== "text") { return []; } - const value = readString(entry, "text"); + const value = readStringField(entry, "text"); return value === undefined ? [] : [value]; }); return parts.length > 0 ? parts.join("").trim() : undefined; diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 9c34a7bc617b..0306f9fddb8b 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -7,6 +7,7 @@ import { type BeforeToolCallFailureDisposition, type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { AttemptFailureSource } from "./attempt-terminal.js"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; import { CodexAssistantProjection } from "./event-projector-assistant.js"; @@ -37,7 +38,6 @@ import { readCodexErrorNotificationMessage, readItem, readItemString, - readString, } from "./event-projector-values.js"; import type { CodexNativePreToolUseFailure } from "./native-hook-relay.js"; import { diff --git a/extensions/codex/src/app-server/managed-binary.ts b/extensions/codex/src/app-server/managed-binary.ts index 68567890f81a..cfbe8e685f01 100644 --- a/extensions/codex/src/app-server/managed-binary.ts +++ b/extensions/codex/src/app-server/managed-binary.ts @@ -299,11 +299,15 @@ function resolveManagedCodexPackageBinCandidate(root: string): string | null { const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { bin?: unknown; }; + const packageBin = + packageJson.bin && typeof packageJson.bin === "object" + ? (packageJson.bin as Record) + : undefined; const binPath = typeof packageJson.bin === "string" ? packageJson.bin - : isRecord(packageJson.bin) && typeof packageJson.bin.codex === "string" - ? packageJson.bin.codex + : typeof packageBin?.codex === "string" + ? packageBin.codex : null; return binPath ? path.resolve(packageRoot, binPath) : null; } catch { @@ -311,10 +315,6 @@ function resolveManagedCodexPackageBinCandidate(root: string): string | null { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - function isDistExtensionRoot(pluginRoot: string, platform: NodeJS.Platform): boolean { const pathApi = pathForPlatform(platform); const extensionsDir = pathApi.dirname(pluginRoot); diff --git a/extensions/codex/src/app-server/native-mcp-app.ts b/extensions/codex/src/app-server/native-mcp-app.ts index ca248025cb18..74b43ec3ed73 100644 --- a/extensions/codex/src/app-server/native-mcp-app.ts +++ b/extensions/codex/src/app-server/native-mcp-app.ts @@ -4,6 +4,10 @@ import { type McpToolCatalog, type SessionMcpRuntime, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { getCodexAppServerClientInstanceId, type CodexAppServerClient } from "./client.js"; import type { CodexMcpServerStatus, CodexThreadItem, JsonObject, JsonValue } from "./protocol.js"; import { retainSharedCodexAppServerClientIfCurrent } from "./shared-client.js"; @@ -15,28 +19,20 @@ type NativeMcpCallToolResult = { _meta?: JsonValue; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readMcpAppResourceUri(item: CodexThreadItem): string | undefined { - const appContext = asRecord(item.appContext); - const uri = readString(appContext?.resourceUri) ?? readString(item.mcpAppResourceUri); + const appContext = asOptionalRecord(item.appContext); + const uri = + normalizeOptionalString(appContext?.resourceUri) ?? + normalizeOptionalString(item.mcpAppResourceUri); return uri?.startsWith("ui://") ? uri : undefined; } function readMcpToolResult(item: CodexThreadItem): NativeMcpCallToolResult | undefined { - const result = asRecord(item.result); + const result = asOptionalRecord(item.result); if (!result || !Array.isArray(result.content)) { return undefined; } - const resultMeta = asRecord(result["_meta"]); + const resultMeta = asOptionalRecord(result["_meta"]); return { content: result.content as JsonValue[], ...(result.structuredContent !== undefined @@ -52,7 +48,7 @@ function readMcpToolResult(item: CodexThreadItem): NativeMcpCallToolResult | und function statusTools(status: CodexMcpServerStatus): Array> { return Object.entries(status.tools).map(([name, value]) => - Object.assign({}, asRecord(value), { name }), + Object.assign({}, asOptionalRecord(value) ?? {}, { name }), ); } @@ -100,8 +96,8 @@ function createNativeMcpRuntime(params: { serverName: status.name, safeServerName: status.name, toolName: String(tool.name), - inputSchema: (asRecord(tool.inputSchema) ?? { type: "object" }) as never, - fallbackDescription: readString(tool.description) ?? String(tool.name), + inputSchema: (asOptionalRecord(tool.inputSchema) ?? { type: "object" }) as never, + fallbackDescription: normalizeOptionalString(tool.description) ?? String(tool.name), })), ), }; @@ -128,7 +124,7 @@ function createNativeMcpRuntime(params: { threadId: params.threadId, server: serverName, tool: toolName, - arguments: (asRecord(input) ?? {}) as JsonObject, + arguments: (asOptionalRecord(input) ?? {}) as JsonObject, })) as never, listTools: async (serverName) => { const status = (await loadStatuses()).find((entry) => entry.name === serverName); @@ -163,8 +159,8 @@ export function createCodexNativeMcpAppResultDetailsPreparer(params: { } const runtime = createNativeMcpRuntime(params); return async (item) => { - const serverName = readString(item.server); - const toolName = readString(item.tool); + const serverName = normalizeOptionalString(item.server); + const toolName = normalizeOptionalString(item.tool); const uiResourceUri = readMcpAppResourceUri(item); const toolResult = readMcpToolResult(item); if (!serverName || !toolName || !uiResourceUri || !toolResult) { diff --git a/extensions/codex/src/app-server/native-subagent-monitor.ts b/extensions/codex/src/app-server/native-subagent-monitor.ts index 3cc5037051d1..fee7dde601af 100644 --- a/extensions/codex/src/app-server/native-subagent-monitor.ts +++ b/extensions/codex/src/app-server/native-subagent-monitor.ts @@ -12,7 +12,11 @@ import { type AgentHarnessTaskRuntimeScope, } from "openclaw/plugin-sdk/agent-harness-task-runtime"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; -import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asFiniteNumber, + normalizeOptionalString, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { claimCodexAppServerLiveThread, releaseCodexAppServerLiveThread, @@ -1835,11 +1839,6 @@ function readThreadSpawnSource(thread: JsonObject | undefined): JsonObject | und return isJsonObject(subAgent?.thread_spawn) ? subAgent.thread_spawn : undefined; } -function readString(record: JsonObject | undefined, key: string): string | undefined { - const value = record?.[key]; - return typeof value === "string" ? value : undefined; -} - function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; diff --git a/extensions/codex/src/app-server/native-subagent-notification.ts b/extensions/codex/src/app-server/native-subagent-notification.ts index 6bb731f0a64a..ef63ea332302 100644 --- a/extensions/codex/src/app-server/native-subagent-notification.ts +++ b/extensions/codex/src/app-server/native-subagent-notification.ts @@ -2,6 +2,7 @@ * Extracts native Codex subagent completion notifications from trusted * inter-agent commentary messages emitted by the app-server. */ +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexServerNotification, JsonObject, JsonValue } from "./protocol.js"; import { isJsonObject } from "./protocol.js"; @@ -253,11 +254,6 @@ function extractSingleTextPart(item: JsonObject): string | undefined { return readString(entry, "text")?.trim(); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - function normalizeStatusKey(value: string): string { return value.replace(/[^a-z0-9]/giu, "").toLowerCase(); } diff --git a/extensions/codex/src/app-server/native-subagent-task-mirror.ts b/extensions/codex/src/app-server/native-subagent-task-mirror.ts index 497f231a37ac..c99ff754fb8a 100644 --- a/extensions/codex/src/app-server/native-subagent-task-mirror.ts +++ b/extensions/codex/src/app-server/native-subagent-task-mirror.ts @@ -3,6 +3,7 @@ * runtime rows so parent sessions can observe child progress. */ import type { AgentHarnessTaskRuntime } from "openclaw/plugin-sdk/agent-harness-task-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CODEX_NATIVE_SUBAGENT_RUN_ID_PREFIX } from "./native-subagent-task-ids.js"; import type { CodexServerNotification, @@ -502,11 +503,6 @@ function readStringArray(value: JsonValue | undefined): string[] { return value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== ""); } -function readString(value: JsonObject, key: string): string | undefined { - const entry = value[key]; - return typeof entry === "string" ? entry : undefined; -} - function readNullableString(value: JsonObject, key: string): string | null | undefined { const entry = value[key]; return typeof entry === "string" || entry === null ? entry : undefined; diff --git a/extensions/codex/src/app-server/notification-correlation.ts b/extensions/codex/src/app-server/notification-correlation.ts index 4a04fe5b7591..b6f09a4535d5 100644 --- a/extensions/codex/src/app-server/notification-correlation.ts +++ b/extensions/codex/src/app-server/notification-correlation.ts @@ -2,6 +2,7 @@ * Correlates Codex app-server notifications with the active thread/turn so * projectors can ignore global or stale events without losing diagnostics. */ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js"; /** Returns true when a notification payload belongs to the exact active thread and turn. */ @@ -27,20 +28,18 @@ export function isCodexNotificationForTurn( */ export function readCodexNotificationThreadId(record: JsonObject): string | undefined { const thread = isJsonObject(record.thread) ? record.thread : undefined; - return readString(record, "threadId") ?? (thread ? readString(thread, "id") : undefined); + return ( + normalizeOptionalString(record.threadId) ?? + (thread ? normalizeOptionalString(thread.id) : undefined) + ); } /** Reads a turn id from either top-level notification params or nested turn payloads. */ export function readCodexNotificationTurnId(record: JsonObject): string | undefined { - return readNestedTurnId(record) ?? readString(record, "turnId"); + return readNestedTurnId(record) ?? normalizeOptionalString(record.turnId); } function readNestedTurnId(record: JsonObject): string | undefined { const turn = record.turn; - return isJsonObject(turn) ? readString(turn, "id") : undefined; -} - -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; + return isJsonObject(turn) ? normalizeOptionalString(turn.id) : undefined; } diff --git a/extensions/codex/src/app-server/rate-limits.ts b/extensions/codex/src/app-server/rate-limits.ts index d37c0e5cded0..c2a6482f0705 100644 --- a/extensions/codex/src/app-server/rate-limits.ts +++ b/extensions/codex/src/app-server/rate-limits.ts @@ -13,7 +13,12 @@ import { type ProviderUsageSnapshot, type UsageWindow, } from "openclaw/plugin-sdk/provider-usage"; -import { asFiniteNumber, parseStrictFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean, + asFiniteNumber, + normalizeOptionalString, + parseStrictFiniteNumber, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js"; const CODEX_LIMIT_ID = "codex"; @@ -61,7 +66,7 @@ export function formatCodexUsageLimitErrorMessage(params: { rateLimitsAuthoritative?: boolean; nowMs?: number; }): string | undefined { - const message = normalizeText(params.message); + const message = normalizeOptionalString(params.message); if (!isCodexUsageLimitError(params.codexErrorInfo)) { return undefined; } @@ -108,7 +113,7 @@ export function formatCodexUsageLimitErrorMessage(params: { export function shouldRefreshCodexRateLimitsForUsageLimitMessage( message: string | null | undefined, ): boolean { - const text = normalizeText(message); + const text = normalizeOptionalString(message); // Only our formatted prefix is a refresh contract. Provider prose alone is // not structural evidence of a Codex usage-limit failure. return Boolean( @@ -274,7 +279,8 @@ function summarizeRateLimitSnapshot(snapshot: JsonObject, nowMs: number): string return window ? [formatRateLimitWindow(key, window, nowMs)] : []; }); const reachedType = - readString(snapshot, "rateLimitReachedType") ?? readString(snapshot, "rate_limit_reached_type"); + normalizeOptionalString(snapshot.rateLimitReachedType) ?? + normalizeOptionalString(snapshot.rate_limit_reached_type); const suffix = reachedType ? ` (${formatReachedType(reachedType)})` : ""; if (windows.length > 0) { return `${label}: ${windows.join(" · ")}${suffix}`; @@ -346,8 +352,10 @@ function addRateLimitSnapshot( seen: Set, ): void { const signature = [ - readNullableString(snapshot, "limitId") ?? readNullableString(snapshot, "limit_id") ?? "", - readNullableString(snapshot, "limitName") ?? readNullableString(snapshot, "limit_name") ?? "", + normalizeOptionalString(snapshot.limitId) ?? normalizeOptionalString(snapshot.limit_id) ?? "", + normalizeOptionalString(snapshot.limitName) ?? + normalizeOptionalString(snapshot.limit_name) ?? + "", formatWindowSignature(snapshot.primary), formatWindowSignature(snapshot.secondary), ].join("|"); @@ -379,7 +387,7 @@ function readRateLimitWindow( if (!isJsonObject(window)) { return undefined; } - const resetsAt = readNumber(window, "resetsAt") ?? readNumber(window, "resets_at"); + const resetsAt = asFiniteNumber(window.resetsAt) ?? asFiniteNumber(window.resets_at); const resetsAtMs = resolveExpiresAtMsFromEpochSeconds(resetsAt, { maxMs: MAX_DATE_TIMESTAMP_MS }) ?? 0; return { @@ -397,8 +405,8 @@ function readRateLimitWindow( function snapshotHasDisplayableData(snapshot: JsonObject): boolean { if ( - readString(snapshot, "rateLimitReachedType") ?? - readString(snapshot, "rate_limit_reached_type") + normalizeOptionalString(snapshot.rateLimitReachedType) ?? + normalizeOptionalString(snapshot.rate_limit_reached_type) ) { return true; } @@ -411,7 +419,7 @@ function readOptionalNumberField( record: JsonObject, ...keys: string[] ): { usedPercent?: number; windowDurationMins?: number } { - const value = keys.map((key) => readNumber(record, key)).find((entry) => entry !== undefined); + const value = keys.map((key) => asFiniteNumber(record[key])).find((entry) => entry !== undefined); if (value === undefined) { return {}; } @@ -436,10 +444,10 @@ function formatRateLimitWindowDetails(window: RateLimitReset, nowMs: number): st function formatLimitLabel(snapshot: JsonObject): string { const label = - readNullableString(snapshot, "limitName") ?? - readNullableString(snapshot, "limit_name") ?? - readNullableString(snapshot, "limitId") ?? - readNullableString(snapshot, "limit_id"); + normalizeOptionalString(snapshot.limitName) ?? + normalizeOptionalString(snapshot.limit_name) ?? + normalizeOptionalString(snapshot.limitId) ?? + normalizeOptionalString(snapshot.limit_id); if (!label || label === CODEX_LIMIT_ID) { return "Codex"; } @@ -465,8 +473,8 @@ function formatAccountResetTime(resetsAtMs: number, nowMs: number): string { function snapshotHasLimitBlock(snapshot: JsonObject): boolean { return Boolean( - readString(snapshot, "rateLimitReachedType") ?? - readString(snapshot, "rate_limit_reached_type") ?? + normalizeOptionalString(snapshot.rateLimitReachedType) ?? + normalizeOptionalString(snapshot.rate_limit_reached_type) ?? readWindowEntries(snapshot).some( (entry) => entry.window.usedPercent !== undefined && entry.window.usedPercent >= 100, ), @@ -474,7 +482,8 @@ function snapshotHasLimitBlock(snapshot: JsonObject): boolean { } function isCodexLimitSnapshot(snapshot: JsonObject): boolean { - const id = readNullableString(snapshot, "limitId") ?? readNullableString(snapshot, "limit_id"); + const id = + normalizeOptionalString(snapshot.limitId) ?? normalizeOptionalString(snapshot.limit_id); return !id || id === CODEX_LIMIT_ID; } @@ -522,7 +531,8 @@ function formatProviderUsageWindowLabel( } function resolveCodexProviderUsagePlan(snapshot: JsonObject): string | undefined { - const plan = readString(snapshot, "planType") ?? readString(snapshot, "plan_type"); + const plan = + normalizeOptionalString(snapshot.planType) ?? normalizeOptionalString(snapshot.plan_type); const credits = isJsonObject(snapshot.credits) ? snapshot.credits : undefined; const creditSummary = formatCodexCreditSummary(credits); if (!creditSummary) { @@ -535,11 +545,11 @@ function formatCodexCreditSummary(credits: JsonObject | undefined): string | und if (!credits) { return undefined; } - const hasCredits = readBoolean(credits, "hasCredits") ?? readBoolean(credits, "has_credits"); + const hasCredits = asBoolean(credits.hasCredits) ?? asBoolean(credits.has_credits); if (hasCredits === false) { return undefined; } - if (readBoolean(credits, "unlimited")) { + if (asBoolean(credits.unlimited)) { return "Unlimited credits"; } const balance = @@ -745,8 +755,8 @@ function formatWindowSignature(value: JsonValue | undefined): string { if (!isJsonObject(value)) { return ""; } - return `${readNumber(value, "usedPercent") ?? readNumber(value, "used_percent") ?? ""}:${ - readNumber(value, "resetsAt") ?? readNumber(value, "resets_at") ?? "" + return `${asFiniteNumber(value.usedPercent) ?? asFiniteNumber(value.used_percent) ?? ""}:${ + asFiniteNumber(value.resetsAt) ?? asFiniteNumber(value.resets_at) ?? "" }`; } @@ -764,26 +774,4 @@ function extractCodexRetryHint(message: string | undefined): string | undefined return tryAgainRelative?.[1]?.trim(); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function readNullableString(record: JsonObject, key: string): string | undefined { - return readString(record, key) ?? undefined; -} - -function readNumber(record: JsonObject, key: string): number | undefined { - return asFiniteNumber(record[key]); -} - -function readBoolean(record: JsonObject, key: string): boolean | undefined { - const value = record[key]; - return typeof value === "boolean" ? value : undefined; -} - -function normalizeText(value: string | null | undefined): string | undefined { - const text = value?.trim(); - return text ? text : undefined; -} /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/codex/src/app-server/run-attempt-state.ts b/extensions/codex/src/app-server/run-attempt-state.ts index 074136da0864..7c5ac24cfad6 100644 --- a/extensions/codex/src/app-server/run-attempt-state.ts +++ b/extensions/codex/src/app-server/run-attempt-state.ts @@ -3,6 +3,11 @@ import { formatErrorMessage, type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + asBoolean, + asFiniteNumber, + hasNonEmptyString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; import { CodexAppServerRpcError } from "./client.js"; import { isJsonObject, type CodexServerNotification } from "./protocol.js"; @@ -147,54 +152,44 @@ export function buildCodexAppServerTimeoutDiagnostics(params: { lastActivityReason?: string; details?: Record; }): NonNullable["diagnostics"] { - const readString = (key: string) => { + const readNonBlankDetailString = (key: string) => { const value = params.details?.[key]; - return typeof value === "string" && value.trim() ? value : undefined; - }; - const readNumber = (key: string) => { - const value = params.details?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; - }; - const readBoolean = (key: string) => { - const value = params.details?.[key]; - return typeof value === "boolean" ? value : undefined; + return hasNonEmptyString(value) ? value : undefined; }; + const activeAppServerTurnRequests = asFiniteNumber(params.details?.activeAppServerTurnRequests); + const activeTurnItemCount = asFiniteNumber(params.details?.activeTurnItemCount); + const terminalTurnNotificationQueued = asBoolean(params.details?.terminalTurnNotificationQueued); + const completionIdleWatchArmed = asBoolean(params.details?.completionIdleWatchArmed); + const assistantCompletionIdleWatchArmed = asBoolean( + params.details?.assistantCompletionIdleWatchArmed, + ); + const terminalIdleWatchArmed = asBoolean(params.details?.terminalIdleWatchArmed); return { ...(params.idleMs !== undefined ? { idleMs: params.idleMs } : {}), ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}), ...(params.lastActivityReason ? { lastActivityReason: params.lastActivityReason } : {}), - ...(readString("lastNotificationMethod") - ? { lastNotificationMethod: readString("lastNotificationMethod") } + ...(readNonBlankDetailString("lastNotificationMethod") + ? { lastNotificationMethod: readNonBlankDetailString("lastNotificationMethod") } : {}), - ...(readString("lastNotificationItemId") - ? { lastNotificationItemId: readString("lastNotificationItemId") } + ...(readNonBlankDetailString("lastNotificationItemId") + ? { lastNotificationItemId: readNonBlankDetailString("lastNotificationItemId") } : {}), - ...(readString("lastNotificationItemType") - ? { lastNotificationItemType: readString("lastNotificationItemType") } + ...(readNonBlankDetailString("lastNotificationItemType") + ? { lastNotificationItemType: readNonBlankDetailString("lastNotificationItemType") } : {}), - ...(readString("lastNotificationItemRole") - ? { lastNotificationItemRole: readString("lastNotificationItemRole") } + ...(readNonBlankDetailString("lastNotificationItemRole") + ? { lastNotificationItemRole: readNonBlankDetailString("lastNotificationItemRole") } : {}), - ...(readString("lastAssistantTextPreview") - ? { lastAssistantTextPreview: readString("lastAssistantTextPreview") } + ...(readNonBlankDetailString("lastAssistantTextPreview") + ? { lastAssistantTextPreview: readNonBlankDetailString("lastAssistantTextPreview") } : {}), - ...(readNumber("activeAppServerTurnRequests") !== undefined - ? { activeAppServerTurnRequests: readNumber("activeAppServerTurnRequests") } - : {}), - ...(readNumber("activeTurnItemCount") !== undefined - ? { activeTurnItemCount: readNumber("activeTurnItemCount") } - : {}), - ...(readBoolean("terminalTurnNotificationQueued") !== undefined - ? { terminalTurnNotificationQueued: readBoolean("terminalTurnNotificationQueued") } - : {}), - ...(readBoolean("completionIdleWatchArmed") !== undefined - ? { completionIdleWatchArmed: readBoolean("completionIdleWatchArmed") } - : {}), - ...(readBoolean("assistantCompletionIdleWatchArmed") !== undefined - ? { assistantCompletionIdleWatchArmed: readBoolean("assistantCompletionIdleWatchArmed") } - : {}), - ...(readBoolean("terminalIdleWatchArmed") !== undefined - ? { terminalIdleWatchArmed: readBoolean("terminalIdleWatchArmed") } + ...(activeAppServerTurnRequests !== undefined ? { activeAppServerTurnRequests } : {}), + ...(activeTurnItemCount !== undefined ? { activeTurnItemCount } : {}), + ...(terminalTurnNotificationQueued !== undefined ? { terminalTurnNotificationQueued } : {}), + ...(completionIdleWatchArmed !== undefined ? { completionIdleWatchArmed } : {}), + ...(assistantCompletionIdleWatchArmed !== undefined + ? { assistantCompletionIdleWatchArmed } : {}), + ...(terminalIdleWatchArmed !== undefined ? { terminalIdleWatchArmed } : {}), }; } diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index d46ffb62015a..9c4138904636 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -15,6 +15,7 @@ import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtim import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { formatSqliteSessionFileMarker } from "openclaw/plugin-sdk/sqlite-runtime-testing"; +import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; // Codex tests cover run attempt.context engine plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it, vi } from "vitest"; @@ -208,10 +209,6 @@ type MockCallReader = { mock: { calls: unknown[][] } }; const requireRecord = createRequireRecord("record", "expected-label-object"); -function optionalString(value: unknown): string { - return typeof value === "string" ? value : ""; -} - function requireFirstCallArg(mock: unknown, label: string): unknown { const call = (mock as MockCallReader).mock.calls[0]; if (!call) { @@ -256,7 +253,7 @@ function getRequestInputTextAt( return input .map((entry) => { const item = requireRecord(entry, "turn/start input entry"); - return item.type === "text" ? optionalString(item.text) : ""; + return item.type === "text" ? (readStringValue(item.text) ?? "") : ""; }) .join("\n"); } @@ -343,7 +340,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { expect(assembleParams.availableTools).toEqual(new Set()); const threadStartParams = requireRequestParams(harness, "thread/start"); - expect(optionalString(threadStartParams.developerInstructions)).toContain( + expect(readStringValue(threadStartParams.developerInstructions) ?? "").toContain( "context-engine system", ); expectRequestInputTextContains(harness, "OpenClaw assembled context for this turn:"); diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index d5e62fd6b2ca..96ae0f183ecb 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -15,6 +15,7 @@ import { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime"; import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { z } from "zod"; import { CODEX_PLUGINS_MARKETPLACE_NAME, @@ -493,7 +494,7 @@ export function createStoredCodexAppServerBinding( lookup?: Omit; } = {}, ): Extract | undefined { - const rawRecord = asRecord(value); + const rawRecord = asOptionalRecord(value); if (!rawRecord) { return undefined; } @@ -1392,12 +1393,6 @@ function stripUndefinedValue(value: unknown): unknown { ); } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readTimestamp(value: unknown): string | undefined { return optionalTimestampSchema.parse(value); } @@ -1406,17 +1401,17 @@ function readPluginAppPolicyContext( value: unknown, bindingSchemaVersion: 1 | 2, ): PluginAppPolicyContext | undefined { - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record || typeof record.fingerprint !== "string") { return undefined; } - const apps = asRecord(record.apps); + const apps = asOptionalRecord(record.apps); if (!apps) { return undefined; } const parsedApps: PluginAppPolicyContext["apps"] = {}; for (const [appId, rawEntry] of Object.entries(apps)) { - const entry = asRecord(rawEntry); + const entry = asOptionalRecord(rawEntry); if (!entry) { return undefined; } diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index 961fc9bba407..eb31408af573 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -18,6 +18,7 @@ import { type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; import { @@ -1356,11 +1357,6 @@ function readNotificationTurnId(record: JsonObject): string | undefined { return readCodexNotificationTurnId(record); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - function formatCodexErrorMessage(params: JsonObject, rateLimits: JsonValue | undefined): Error { const error = isJsonObject(params.error) ? params.error : undefined; const message = diff --git a/extensions/codex/src/app-server/usage-limit-error.ts b/extensions/codex/src/app-server/usage-limit-error.ts index 38873bb4a32e..9a6799d92cba 100644 --- a/extensions/codex/src/app-server/usage-limit-error.ts +++ b/extensions/codex/src/app-server/usage-limit-error.ts @@ -8,14 +8,10 @@ import { type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { markAuthProfileBlockedUntil } from "openclaw/plugin-sdk/agent-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { CODEX_CONTROL_METHODS } from "./capabilities.js"; import type { CodexAppServerClient } from "./client.js"; -import { - isJsonObject, - type CodexServerNotification, - type JsonObject, - type JsonValue, -} from "./protocol.js"; +import { isJsonObject, type CodexServerNotification, type JsonValue } from "./protocol.js"; import { readCodexRateLimitsRevision, readRecentCodexRateLimits, @@ -257,8 +253,3 @@ function readCodexErrorPayload(error: unknown): { rateLimits, }; } - -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} diff --git a/extensions/codex/src/app-server/user-input-bridge.ts b/extensions/codex/src/app-server/user-input-bridge.ts index e6192231a543..519b95ec24d3 100644 --- a/extensions/codex/src/app-server/user-input-bridge.ts +++ b/extensions/codex/src/app-server/user-input-bridge.ts @@ -11,6 +11,7 @@ import { type AgentHarnessUserInputQuestion, type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatCodexDisplayText } from "../command-formatters.js"; import { isJsonObject, @@ -314,11 +315,6 @@ function emptyUserInputResponse(): JsonObject { return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject; } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - function readRequestId(record: JsonObject): string | number | undefined { const value = record.requestId; return typeof value === "string" || typeof value === "number" ? value : undefined; diff --git a/extensions/codex/src/command-account.ts b/extensions/codex/src/command-account.ts index b036d8beb34b..ef634cefaec9 100644 --- a/extensions/codex/src/command-account.ts +++ b/extensions/codex/src/command-account.ts @@ -10,9 +10,12 @@ import { type AuthProfileStore, } from "openclaw/plugin-sdk/agent-runtime"; import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; -import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeOptionalString, + normalizeUniqueStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js"; -import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js"; +import { isJsonObject, type JsonValue } from "./app-server/protocol.js"; import { summarizeCodexAccountUsage, type CodexAccountUsageSummary, @@ -278,9 +281,9 @@ function resolveLiveAccountProfileId(params: { const account = isJsonObject(params.account.value.account) ? params.account.value.account : params.account.value; - const type = readString(account, "type")?.toLowerCase(); + const type = normalizeOptionalString(account.type)?.toLowerCase(); if (type === "chatgpt") { - const email = readString(account, "email")?.toLowerCase(); + const email = normalizeOptionalString(account.email)?.toLowerCase(); const firstSubscription = params.order.find((profileId) => isChatGptSubscriptionProfile(params.store.profiles[profileId]), ); @@ -437,11 +440,6 @@ function formatUsageLineForDisplay(value: string): string { return value.replace(/^weekly\b/u, "Weekly").replace(/\bshort-term\b/u, "Short-term"); } -function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function isChatGptSubscriptionProfile(credential: AuthProfileCredential | undefined): boolean { return credential?.type === "oauth" || credential?.type === "token"; } diff --git a/extensions/codex/src/command-diagnostics.ts b/extensions/codex/src/command-diagnostics.ts index 8f1ddb103511..d011e4b2db98 100644 --- a/extensions/codex/src/command-diagnostics.ts +++ b/extensions/codex/src/command-diagnostics.ts @@ -1,6 +1,7 @@ import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; import { parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexAppServerAuthProfileIdForAgent } from "./app-server/auth-bridge.js"; import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import { isJsonObject } from "./app-server/protocol.js"; @@ -29,7 +30,6 @@ import { readPendingCodexDiagnosticsConfirmation, recordCodexDiagnosticsUpload, } from "./command-diagnostics-support.js"; -import { readString } from "./command-formatters.js"; import { CODEX_CONTROL_METHODS, type CodexCommandDeps } from "./command-handler-deps.js"; import { resolveControlTarget } from "./command-handler-scope.js"; @@ -345,7 +345,7 @@ async function sendCodexDiagnosticsFeedbackForTargets( continue; } const responseThreadId = isJsonObject(response.value) - ? readString(response.value, "threadId") + ? normalizeOptionalString(response.value.threadId) : undefined; sent.push({ ...target, threadId: responseThreadId ?? target.threadId }); recordCodexDiagnosticsUpload(target.threadId, ctx, now, options.cooldownScope); diff --git a/extensions/codex/src/command-formatters.ts b/extensions/codex/src/command-formatters.ts index 4cd3882c4a71..1c80f42ad9a9 100644 --- a/extensions/codex/src/command-formatters.ts +++ b/extensions/codex/src/command-formatters.ts @@ -2,9 +2,10 @@ * Formats Codex command responses for safe chat display, including status, * lists, account summaries, and user-facing help text. */ +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CodexComputerUseStatus } from "./app-server/computer-use.js"; import type { CodexAppServerModelListResult } from "./app-server/models.js"; -import { isJsonObject, type JsonObject, type JsonValue } from "./app-server/protocol.js"; +import { isJsonObject, type JsonValue } from "./app-server/protocol.js"; import { hasCodexRateLimitSnapshots, summarizeCodexAccountRateLimits, @@ -96,13 +97,18 @@ export function formatThreads(response: JsonValue | undefined): string { "Codex threads:", ...threads.slice(0, 10).map((thread) => { const record = isJsonObject(thread) ? thread : {}; - const id = readString(record, "threadId") ?? readString(record, "id") ?? ""; + const id = + normalizeOptionalString(record.threadId) ?? + normalizeOptionalString(record.id) ?? + ""; const title = - readString(record, "title") ?? readString(record, "name") ?? readString(record, "summary"); + normalizeOptionalString(record.title) ?? + normalizeOptionalString(record.name) ?? + normalizeOptionalString(record.summary); const details = [ - readString(record, "model"), - readString(record, "cwd"), - readString(record, "updatedAt") ?? readString(record, "lastUpdatedAt"), + normalizeOptionalString(record.model), + normalizeOptionalString(record.cwd), + normalizeOptionalString(record.updatedAt) ?? normalizeOptionalString(record.lastUpdatedAt), ].filter((value): value is string => Boolean(value)); return `- ${formatCodexDisplayText(id)}${title ? ` - ${formatCodexDisplayText(title)}` : ""}${ details.length > 0 ? ` (${details.map(formatCodexDisplayText).join(", ")})` : "" @@ -222,7 +228,9 @@ export function formatList(response: JsonValue | undefined, label: string): stri ...entries.slice(0, 25).map((entry) => { const record = isJsonObject(entry) ? entry : {}; return `- ${formatCodexDisplayText( - readString(record, "name") ?? readString(record, "id") ?? JSON.stringify(entry), + normalizeOptionalString(record.name) ?? + normalizeOptionalString(record.id) ?? + JSON.stringify(entry), )}`; }), ].join("\n"); @@ -267,7 +275,7 @@ export function formatSkills(response: JsonValue | undefined): string { function formatCodexSkillEntry(entry: JsonValue): string { const record = isJsonObject(entry) ? entry : {}; - const name = readString(record, "name") ?? ""; + const name = normalizeOptionalString(record.name) ?? ""; return `\`${formatCodexDisplayText(name)}\``; } @@ -406,15 +414,15 @@ function summarizeAccount(value: JsonValue | undefined): string { return "unavailable"; } const account = isJsonObject(value.account) ? value.account : value; - const accountType = readString(account, "type"); + const accountType = normalizeOptionalString(account.type); if (accountType === "amazonBedrock") { return "Amazon Bedrock"; } return ( - readString(account, "email") ?? - readString(account, "accountEmail") ?? - readString(account, "planType") ?? - readString(account, "id") ?? + normalizeOptionalString(account.email) ?? + normalizeOptionalString(account.accountEmail) ?? + normalizeOptionalString(account.planType) ?? + normalizeOptionalString(account.id) ?? "available" ); } @@ -501,7 +509,8 @@ function isMeaningfulRateLimitSnapshot(value: JsonValue | undefined): boolean { return false; } const reachedType = - readString(value, "rateLimitReachedType") ?? readString(value, "rate_limit_reached_type"); + normalizeOptionalString(value.rateLimitReachedType) ?? + normalizeOptionalString(value.rate_limit_reached_type); if (reachedType) { return true; } @@ -526,9 +535,3 @@ function extractArray(value: JsonValue | undefined): JsonValue[] { } return []; } - -/** Reads and trims a non-empty string field from a JSON object. */ -export function readString(record: JsonObject, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} diff --git a/extensions/codex/src/command-handler-actions.ts b/extensions/codex/src/command-handler-actions.ts index 354f1145bee6..70f029542392 100644 --- a/extensions/codex/src/command-handler-actions.ts +++ b/extensions/codex/src/command-handler-actions.ts @@ -1,6 +1,7 @@ import { MODEL_SELECTION_LOCKED_MESSAGE } from "openclaw/plugin-sdk/model-session-runtime"; import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import type { CodexComputerUseSetupParams } from "./app-server/computer-use.js"; import { isJsonObject, type JsonValue } from "./app-server/protocol.js"; @@ -9,11 +10,7 @@ import { resolveCodexNativeSandboxBlock, } from "./app-server/sandbox-guard.js"; import { canMutateCodexHost } from "./command-authorization.js"; -import { - formatCodexDisplayText, - formatComputerUseStatus, - readString, -} from "./command-formatters.js"; +import { formatCodexDisplayText, formatComputerUseStatus } from "./command-formatters.js"; import { formatComputerUsePersistentIdentityMigration, parseBindArgs, @@ -260,8 +257,8 @@ function formatNativeGoal(response: JsonValue | undefined): string { if (!goal) { return "No Codex goal is active."; } - const objective = readString(goal, "objective") ?? "unknown"; - const status = readString(goal, "status") ?? "unknown"; + const objective = normalizeOptionalString(goal.objective) ?? "unknown"; + const status = normalizeOptionalString(goal.status) ?? "unknown"; const tokensUsed = typeof goal.tokensUsed === "number" ? goal.tokensUsed : 0; const tokenBudget = typeof goal.tokenBudget === "number" ? goal.tokenBudget : undefined; return [ diff --git a/extensions/codex/src/conversation-binding-data.ts b/extensions/codex/src/conversation-binding-data.ts index e8e0f352fb9f..ae07b54eb895 100644 --- a/extensions/codex/src/conversation-binding-data.ts +++ b/extensions/codex/src/conversation-binding-data.ts @@ -2,7 +2,10 @@ import { createHash, randomUUID } from "node:crypto"; import process from "node:process"; import type { PluginConversationBinding } from "openclaw/plugin-sdk/plugin-entry"; -import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord as readRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const APP_SERVER_BINDING_DATA_VERSION = 2; const CLI_BINDING_DATA_VERSION = 1; @@ -160,10 +163,10 @@ export function readCodexConversationBindingDataRecord( function readConversationSource( value: CodexAppServerConversationSource | Record | undefined, ): CodexAppServerConversationSource | undefined { - const agentId = readString(value, "agentId"); - const sessionId = readString(value, "sessionId"); - const threadId = readString(value, "threadId"); - const sessionKey = readString(value, "sessionKey"); + const agentId = normalizeOptionalString(value?.agentId); + const sessionId = normalizeOptionalString(value?.sessionId); + const threadId = normalizeOptionalString(value?.threadId); + const sessionKey = normalizeOptionalString(value?.sessionKey); if (!agentId || !sessionId || !threadId) { return undefined; } @@ -182,15 +185,10 @@ export function legacyCodexConversationBindingId(sessionFile: string): string { export function resolveCodexDefaultWorkspaceDir(pluginConfig: unknown): string { const appServer = readRecord(readRecord(pluginConfig)?.appServer); - const configured = readString(appServer, "defaultWorkspaceDir"); + const configured = normalizeOptionalString(appServer?.defaultWorkspaceDir); return configured ?? process.cwd(); } -function readString(record: Record | undefined, key: string) { - const value = record?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readConversationStart( value: CodexAppServerConversationStart | Record | undefined, ): CodexAppServerConversationStart | undefined { diff --git a/extensions/codex/src/conversation-turn-collector.ts b/extensions/codex/src/conversation-turn-collector.ts index 4420b36971bd..1d507ec0c670 100644 --- a/extensions/codex/src/conversation-turn-collector.ts +++ b/extensions/codex/src/conversation-turn-collector.ts @@ -1,6 +1,9 @@ // Codex plugin module implements conversation turn collector behavior. import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; -import { asOptionalRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord as readRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isAssistantCommentaryCompletionNotification } from "./app-server/attempt-notifications.js"; import { isCodexNotificationForTurn } from "./app-server/notification-correlation.js"; import { @@ -57,7 +60,7 @@ export function createCodexConversationTurnCollector(threadId: string) { return; } if (notification.method === "item/agentMessage/delta") { - const itemId = readString(params, "itemId") ?? "assistant"; + const itemId = normalizeOptionalString(params.itemId) ?? "assistant"; const delta = readTextString(params, "delta"); if (!delta) { return; @@ -68,7 +71,8 @@ export function createCodexConversationTurnCollector(threadId: string) { if (notification.method === "item/completed") { const item = isJsonObject(params.item) ? params.item : undefined; if (item?.type === "agentMessage") { - const itemId = readString(item, "id") ?? readString(params, "itemId") ?? "assistant"; + const itemId = + normalizeOptionalString(item.id) ?? normalizeOptionalString(params.itemId) ?? "assistant"; assistantTextByItem.delete(itemId); if (isAssistantCommentaryCompletionNotification(notification)) { return; @@ -82,10 +86,11 @@ export function createCodexConversationTurnCollector(threadId: string) { } if (notification.method === "turn/completed") { const turn = isJsonObject(params.turn) ? params.turn : undefined; - const status = readString(turn, "status"); + const status = normalizeOptionalString(turn?.status); if (status === "failed") { failedError = - readString(readRecord(turn?.error), "message") ?? "codex app-server turn failed"; + normalizeOptionalString(readRecord(turn?.error)?.message) ?? + "codex app-server turn failed"; } else if (status === "interrupted") { // Codex reports an interrupted turn as a terminal completion without a // final answer; streamed partial text must not become a successful reply. @@ -99,7 +104,8 @@ export function createCodexConversationTurnCollector(threadId: string) { if (!isJsonObject(item) || item.type !== "agentMessage") { continue; } - const itemId = readString(item, "id") ?? `assistant-${assistantTextByItem.size + 1}`; + const itemId = + normalizeOptionalString(item.id) ?? `assistant-${assistantTextByItem.size + 1}`; assistantTextByItem.delete(itemId); const text = item.phase === "commentary" ? undefined : readTextString(item, "text"); if (text?.trim()) { @@ -139,11 +145,6 @@ export function createCodexConversationTurnCollector(threadId: string) { }; } -function readString(record: Record | JsonObject | undefined, key: string) { - const value = record?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readTextString(record: Record | JsonObject | undefined, key: string) { const value = record?.[key]; return typeof value === "string" && value.length > 0 ? value : undefined; diff --git a/extensions/codex/src/native-thread-tool.ts b/extensions/codex/src/native-thread-tool.ts index ea05ceb7c2d3..fda1c0fa7f85 100644 --- a/extensions/codex/src/native-thread-tool.ts +++ b/extensions/codex/src/native-thread-tool.ts @@ -12,6 +12,7 @@ import { ModelSelectionLockedError, } from "openclaw/plugin-sdk/model-session-runtime"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; +import { asBoolean, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import { resolveCodexBindingAppServerConnection } from "./app-server/binding-connection.js"; import { CODEX_CONTROL_METHODS } from "./app-server/capabilities.js"; @@ -111,16 +112,6 @@ type CodexThreadsToolOptions = { request?: typeof codexControlRequest; }; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function readBoolean(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; -} - function readLimit(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100 ? value @@ -279,7 +270,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge "Manage native Codex threads: list, read, fork, rename, archive (confirm:true), unarchive. When supervision is enabled, raw transcript reads and every mutation require their matching supervision policy option.", parameters: CodexThreadsParamsSchema, async execute(_toolCallId, rawParams) { - const params = asRecord(rawParams); + const params = asOptionalRecord(rawParams) ?? {}; const action = readStringParam(params, "action", { required: true, label: "action" }); const pluginConfig = options.getPluginConfig(); const plugin = readCodexPluginConfig(pluginConfig); @@ -299,7 +290,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge pluginConfig, CODEX_CONTROL_METHODS.listThreads, { - archived: readBoolean(params.archived), + archived: asBoolean(params.archived) ?? false, limit: readLimit(params.limit) ?? 20, modelProviders: [], sortKey: "recency_at", @@ -315,7 +306,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge const threadId = readThreadId(params); if (action === "read") { - const includeTurns = readBoolean(params.include_turns); + const includeTurns = asBoolean(params.include_turns) ?? false; if (includeTurns && !mayReadRawTranscripts) { throw new Error( "Codex raw transcript reads are disabled for this codex plugin supervision config.", @@ -429,7 +420,7 @@ export function createCodexThreadsTool(options: CodexThreadsToolOptions): AnyAge throw new Error(`unsupported codex_threads action: ${action}`); } - const attach = readBoolean(params.attach, true); + const attach = asBoolean(params.attach) ?? true; if (attach && !session) { throw new Error("cannot attach a Codex fork without an active OpenClaw session"); } diff --git a/extensions/codex/src/session-catalog-parsing.ts b/extensions/codex/src/session-catalog-parsing.ts index c77654198f67..7420c4482156 100644 --- a/extensions/codex/src/session-catalog-parsing.ts +++ b/extensions/codex/src/session-catalog-parsing.ts @@ -179,7 +179,7 @@ export function normalizeLimit(value: unknown, key: string): number { return value as number; } -export function readOptionalString( +export function readBoundedOptionalString( params: Record, key: string, maxLength: number, @@ -217,9 +217,9 @@ export function readPageParams(value: unknown): CodexSessionCatalogPageParams { } const params = value; requireOnlyKeys(params, new Set(["cursor", "limit", "searchTerm", "cwd"])); - const cursor = readOptionalString(params, "cursor", MAX_CURSOR_LENGTH); - const searchTerm = readOptionalString(params, "searchTerm", MAX_SEARCH_LENGTH); - const cwd = readOptionalString(params, "cwd", MAX_CWD_LENGTH); + const cursor = readBoundedOptionalString(params, "cursor", MAX_CURSOR_LENGTH); + const searchTerm = readBoundedOptionalString(params, "searchTerm", MAX_SEARCH_LENGTH); + const cwd = readBoundedOptionalString(params, "cwd", MAX_CWD_LENGTH); return { limit: normalizeLimit(params.limit, "limit"), ...(cursor ? { cursor } : {}), @@ -234,7 +234,7 @@ export function readGatewayParams(value: unknown): CodexSessionCatalogParams { } const params = isRecord(value) ? value : {}; requireOnlyKeys(params, new Set(["search", "limitPerHost", "hostIds", "cursors"])); - const search = readOptionalString(params, "search", MAX_SEARCH_LENGTH); + const search = readBoundedOptionalString(params, "search", MAX_SEARCH_LENGTH); let hostIds: string[] | undefined; if (params.hostIds !== undefined) { if (!Array.isArray(params.hostIds) || params.hostIds.length > MAX_HOST_COUNT) { diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts index d7b0a1ff27ea..628bd64675d5 100644 --- a/extensions/codex/src/session-catalog.ts +++ b/extensions/codex/src/session-catalog.ts @@ -87,7 +87,7 @@ import { parseTranscriptPage, readControlCursor, readGatewayParams, - readOptionalString, + readBoundedOptionalString, readPageParams, requireBoundThread, requireOnlyKeys, @@ -731,11 +731,11 @@ function readNodeTranscriptParams(value: unknown): CodexNodeSessionTranscriptPar throw new CatalogParamsError("Codex session read parameters must be an object"); } requireOnlyKeys(value, new Set(["threadId", "cursor", "limit"])); - const threadId = readOptionalString(value, "threadId", MAX_SESSION_ID_LENGTH); + const threadId = readBoundedOptionalString(value, "threadId", MAX_SESSION_ID_LENGTH); if (!threadId) { throw new CatalogParamsError("threadId is required"); } - const cursor = readOptionalString(value, "cursor", MAX_CURSOR_LENGTH); + const cursor = readBoundedOptionalString(value, "cursor", MAX_CURSOR_LENGTH); const limit = readBoundedLimit( value.limit, "limit", diff --git a/extensions/codex/src/supervision-tools.ts b/extensions/codex/src/supervision-tools.ts index af47084fdecc..ca60c5f40ed7 100644 --- a/extensions/codex/src/supervision-tools.ts +++ b/extensions/codex/src/supervision-tools.ts @@ -157,10 +157,6 @@ type CodexSupervisionToolsOptions = { useLegacyMcpPolicyEnv?: boolean; }; -function asRecord(value: unknown): Record { - return isRecord(value) ? value : {}; -} - function asRecordArray(value: unknown): Record[] { return Array.isArray(value) ? value.filter(isRecord) : []; } @@ -1081,7 +1077,7 @@ export function createCodexSupervisionTools(options: CodexSupervisionToolsOption description: "List Codex sessions visible to the OpenClaw supervisor.", parameters: SessionsListParamsSchema, execute: async (_toolCallId, rawParams) => { - const params = asRecord(rawParams); + const params = isRecord(rawParams) ? rawParams : {}; const { endpoints } = current(); const result = await listSessionSnapshot({ endpoints, @@ -1107,7 +1103,7 @@ export function createCodexSupervisionTools(options: CodexSupervisionToolsOption execute: async (_toolCallId, rawParams) => { const { endpoints, pluginConfig } = current(); requireRawTranscriptAccess(options, pluginConfig); - const params = asRecord(rawParams); + const params = isRecord(rawParams) ? rawParams : {}; const threadId = readStringParam(params, "thread_id", { required: true }); const endpoint = await resolveEndpointForThread({ endpoints, @@ -1137,7 +1133,7 @@ export function createCodexSupervisionTools(options: CodexSupervisionToolsOption execute: async (_toolCallId, rawParams) => { const { endpoints, pluginConfig } = current(); requireWriteAccess(options, pluginConfig); - const params = asRecord(rawParams); + const params = isRecord(rawParams) ? rawParams : {}; const threadId = readStringParam(params, "thread_id", { required: true }); const text = readStringParam(params, "text", { required: true, allowEmpty: false }); const mode = readModeParam(params) ?? "auto"; @@ -1186,7 +1182,7 @@ export function createCodexSupervisionTools(options: CodexSupervisionToolsOption execute: async (_toolCallId, rawParams) => { const { endpoints, pluginConfig } = current(); requireWriteAccess(options, pluginConfig); - const params = asRecord(rawParams); + const params = isRecord(rawParams) ? rawParams : {}; const threadId = readStringParam(params, "thread_id", { required: true }); const endpoint = await resolveEndpointForThread({ endpoints, diff --git a/extensions/copilot/src/attempt-cleanup.ts b/extensions/copilot/src/attempt-cleanup.ts index 52aaf1fecd5d..d60ba06c4731 100644 --- a/extensions/copilot/src/attempt-cleanup.ts +++ b/extensions/copilot/src/attempt-cleanup.ts @@ -2,7 +2,7 @@ import { awaitAgentEndSideEffects, runAgentEndSideEffects, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { toError } from "./attempt-config.js"; +import { toCopilotError } from "./attempt-config.js"; import { BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS, type AgentHarnessAttemptResult, @@ -31,7 +31,7 @@ export async function finalizeCopilotAttempt( messages: result.messagesSnapshot, success: !aborted && !failure && !timedOut, ...(failure - ? { error: toError(failure.error).message } + ? { error: toCopilotError(failure.error).message } : timedOut ? { error: "Copilot SDK turn timed out." } : {}), diff --git a/extensions/copilot/src/attempt-config.ts b/extensions/copilot/src/attempt-config.ts index a2921bfab7c2..2592271a2f87 100644 --- a/extensions/copilot/src/attempt-config.ts +++ b/extensions/copilot/src/attempt-config.ts @@ -129,8 +129,8 @@ export function createResult( messagingToolSentTargets: [], messagingToolSentTexts: [], replayMetadata, - sessionFileUsed: readString(params.sessionFile), - sessionIdUsed: state.sessionIdUsed ?? readString(params.sessionId) ?? "copilot-session", + sessionFileUsed: readNonEmptyString(params.sessionFile), + sessionIdUsed: state.sessionIdUsed ?? readNonEmptyString(params.sessionId) ?? "copilot-session", toolMetas, yieldDetected: state.yieldDetected === true, }; @@ -323,7 +323,7 @@ export function createSystemMessageContent( if (bootstrap) { sections.push(bootstrap); } - const extraSystemPrompt = readString(params.extraSystemPrompt)?.trim(); + const extraSystemPrompt = readNonEmptyString(params.extraSystemPrompt)?.trim(); if (extraSystemPrompt) { const contextHeader = params.promptMode === "minimal" ? "## Subagent Context" : "## Conversation Context"; @@ -334,11 +334,11 @@ export function createSystemMessageContent( export function isRawCopilotModelRun(params: AttemptParamsLike): boolean { return params.modelRun === true || params.promptMode === "none"; } -export function readString(value: unknown): string | undefined { +export function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } export function readResolvedAttemptPath(value: unknown): string | undefined { - const raw = readString(value)?.trim(); + const raw = readNonEmptyString(value)?.trim(); if (!raw) { return undefined; } @@ -354,20 +354,20 @@ export function resolveModelRef(params: AttemptParamsLike): ModelRef { const requestTransport = getModelProviderRequestTransport(rawModel); const rawRequest = model.request; return { - api: readString(model.api), + api: readNonEmptyString(model.api), id: - readString(model.id) ?? - readString((params as { modelId?: unknown }).modelId) ?? + readNonEmptyString(model.id) ?? + readNonEmptyString((params as { modelId?: unknown }).modelId) ?? "unknown-model", provider: - readString(model.provider) ?? - readString((params as { provider?: unknown }).provider) ?? + readNonEmptyString(model.provider) ?? + readNonEmptyString((params as { provider?: unknown }).provider) ?? "unknown-provider", - baseUrl: readString(model.baseUrl), - azureApiVersion: readString(model.azureApiVersion ?? model.params?.azureApiVersion), + baseUrl: readNonEmptyString(model.baseUrl), + azureApiVersion: readNonEmptyString(model.azureApiVersion ?? model.params?.azureApiVersion), headers: model.headers, authHeader: model.authHeader, - requestAuthMode: readString(requestTransport?.auth?.mode ?? rawRequest?.auth?.mode), + requestAuthMode: readNonEmptyString(requestTransport?.auth?.mode ?? rawRequest?.auth?.mode), requestProxy: requestTransport?.proxy ?? rawRequest?.proxy, requestTls: requestTransport?.tls ?? rawRequest?.tls, requestAllowPrivateNetwork: @@ -379,10 +379,10 @@ export function resolveModelRef(params: AttemptParamsLike): ModelRef { } return { id: - readString(typeof rawModel === "string" ? rawModel : undefined) ?? - readString((params as { modelId?: unknown }).modelId) ?? + readNonEmptyString(typeof rawModel === "string" ? rawModel : undefined) ?? + readNonEmptyString((params as { modelId?: unknown }).modelId) ?? "unknown-model", - provider: readString((params as { provider?: unknown }).provider) ?? "unknown-provider", + provider: readNonEmptyString((params as { provider?: unknown }).provider) ?? "unknown-provider", }; } export function resolvePoolAcquire(params: AttemptParamsLike): { @@ -394,28 +394,28 @@ export function resolvePoolAcquire(params: AttemptParamsLike): { const model = resolveModelRef(params); const provider = resolveCopilotProvider({ model, - resolvedApiKey: readString(params.resolvedApiKey), - authProfileId: readString(params.authProfileId), + resolvedApiKey: readNonEmptyString(params.resolvedApiKey), + authProfileId: readNonEmptyString(params.authProfileId), }); const auth = provider.mode === "byok" ? createCopilotByokAuth({ - agentId: readString(params.agentId), - agentDir: readString(params.agentDir), - workspaceDir: readString(params.workspaceDir), - copilotHome: readString(params.copilotHome), + agentId: readNonEmptyString(params.agentId), + agentDir: readNonEmptyString(params.agentDir), + workspaceDir: readNonEmptyString(params.workspaceDir), + copilotHome: readNonEmptyString(params.copilotHome), authProfileId: provider.authProfileId, authProfileVersion: provider.authProfileVersion, }) : resolveCopilotAuth({ - agentId: readString(params.agentId), - agentDir: readString(params.agentDir), - workspaceDir: readString(params.workspaceDir), - copilotHome: readString(params.copilotHome), + agentId: readNonEmptyString(params.agentId), + agentDir: readNonEmptyString(params.agentDir), + workspaceDir: readNonEmptyString(params.workspaceDir), + copilotHome: readNonEmptyString(params.copilotHome), auth: params.auth, - resolvedApiKey: readString(params.resolvedApiKey), - authProfileId: readString(params.authProfileId), - profileVersion: readString(params.profileVersion), + resolvedApiKey: readNonEmptyString(params.resolvedApiKey), + authProfileId: readNonEmptyString(params.authProfileId), + profileVersion: readNonEmptyString(params.profileVersion), }); return { key: { @@ -440,7 +440,7 @@ export function resolvePoolAcquire(params: AttemptParamsLike): { provider, }; } -export function toError(error: unknown): Error { +export function toCopilotError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } export function isSdkSendAndWaitTimeoutError(error: unknown): boolean { diff --git a/extensions/copilot/src/attempt-execution.ts b/extensions/copilot/src/attempt-execution.ts index 69692b2c2758..1ba21239b0b8 100644 --- a/extensions/copilot/src/attempt-execution.ts +++ b/extensions/copilot/src/attempt-execution.ts @@ -20,10 +20,10 @@ import { createPromptError, createResult, isSdkSendAndWaitTimeoutError, + readNonEmptyString, readResolvedAttemptPath, - readString, resolvePoolAcquire, - toError, + toCopilotError, } from "./attempt-config.js"; import { completeCopilotAttempt } from "./attempt-finalize.js"; import { resolveCopilotAttemptSandbox } from "./attempt-prepare.js"; @@ -179,7 +179,7 @@ export async function runCopilotExecution(context: { now, promptError: createPromptError( "sandbox_resolution_failure", - `[copilot-attempt] sandbox resolution failed: ${toError(error).message}`, + `[copilot-attempt] sandbox resolution failed: ${toCopilotError(error).message}`, error, ), sdkSessionId: undefined, @@ -237,7 +237,7 @@ export async function runCopilotExecution(context: { createResult(input, { messagesSnapshot: messages, now, - promptError: createPromptError("model_not_supported", toError(error).message, error), + promptError: createPromptError("model_not_supported", toCopilotError(error).message, error), sdkSessionId: undefined, sessionIdUsed: input.sessionId, }), @@ -265,10 +265,10 @@ export async function runCopilotExecution(context: { allowModelTools: poolAcquire.provider.mode === "byok", modelProvider: modelRef.provider, modelId: modelRef.id, - agentId: readString(params.agentId) ?? "copilot", - sessionId: readString(input.sessionId) ?? "copilot-session", - sessionKey: readString((input as { sessionKey?: unknown }).sessionKey), - agentDir: readString(input.agentDir), + agentId: readNonEmptyString(params.agentId) ?? "copilot", + sessionId: readNonEmptyString(input.sessionId) ?? "copilot-session", + sessionKey: readNonEmptyString((input as { sessionKey?: unknown }).sessionKey), + agentDir: readNonEmptyString(input.agentDir), workspaceDir: effectiveWorkspaceDir, cwd: effectiveCwd, sandbox, @@ -309,7 +309,7 @@ export async function runCopilotExecution(context: { now, promptError: createPromptError( "tool_bridge_failure", - `[copilot-attempt] tool-bridge construction failed: ${toError(error).message}`, + `[copilot-attempt] tool-bridge construction failed: ${toCopilotError(error).message}`, error, ), sdkSessionId: undefined, @@ -366,7 +366,7 @@ export async function runCopilotExecution(context: { if (settledToolFinalization) { throw createPromptError( "settled_finalization_resume_failed", - `[copilot-attempt] settled tool finalization could not resume the existing Copilot SDK session: ${toError(error).message}`, + `[copilot-attempt] settled tool finalization could not resume the existing Copilot SDK session: ${toCopilotError(error).message}`, error, ); } @@ -386,8 +386,8 @@ export async function runCopilotExecution(context: { } sessionRef.current = session; sdkSessionId = - readString(session.sessionId) ?? - readString(session.id) ?? + readNonEmptyString(session.sessionId) ?? + readNonEmptyString(session.id) ?? (resumeFailureRecovered ? undefined : resumeSessionId); if (!sdkSessionId) { throw createPromptError( @@ -428,7 +428,7 @@ export async function runCopilotExecution(context: { if (settledToolFinalization) { return; } - const sessionFile = readString(input.sessionFile); + const sessionFile = readNonEmptyString(input.sessionFile); if (!sessionFile) { return; } @@ -441,7 +441,7 @@ export async function runCopilotExecution(context: { if (settledToolFinalization) { return; } - const sessionFile = readString(input.sessionFile); + const sessionFile = readNonEmptyString(input.sessionFile); if (!success || !sessionFile) { return; } @@ -525,15 +525,15 @@ export async function runCopilotExecution(context: { try { await transcriptJournal?.barrier("timeout"); } catch (transcriptError) { - promptError = toError(transcriptError); + promptError = toCopilotError(transcriptError); } } else { try { bridge?.flushTranscriptProjection(); await transcriptJournal?.barrier("attempt error"); - promptError = toError(error); + promptError = toCopilotError(error); } catch (transcriptError) { - promptError = toError(transcriptError); + promptError = toCopilotError(transcriptError); } } } @@ -543,7 +543,7 @@ export async function runCopilotExecution(context: { bridge?.flushTranscriptProjection(); await transcriptJournal?.barrier("bridge detach"); } catch (transcriptError) { - promptError = toError(transcriptError); + promptError = toCopilotError(transcriptError); } userInputBridgeRef?.cancelPending(); if (activeRunHandleRef) { @@ -612,7 +612,7 @@ export async function runCopilotExecution(context: { try { await session.disconnect(); } catch (error: unknown) { - disconnectError = toError(error); + disconnectError = toCopilotError(error); if (!promptError && !timedOut) { promptError = disconnectError; } @@ -622,7 +622,7 @@ export async function runCopilotExecution(context: { try { await deps.pool.release(handle); } catch (error: unknown) { - const releaseFailure = toError(error); + const releaseFailure = toCopilotError(error); if (promptError) { console.warn( "[copilot-attempt] pool.release failed after primary error", diff --git a/extensions/copilot/src/attempt-prepare.ts b/extensions/copilot/src/attempt-prepare.ts index c54c517c6c16..36a33ae3846b 100644 --- a/extensions/copilot/src/attempt-prepare.ts +++ b/extensions/copilot/src/attempt-prepare.ts @@ -6,7 +6,7 @@ import { resolveSandboxContext as defaultResolveSandboxContext, resolveSessionAgentIds, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { readResolvedAttemptPath, readString, resolveModelRef } from "./attempt-config.js"; +import { readNonEmptyString, readResolvedAttemptPath, resolveModelRef } from "./attempt-config.js"; import type { AttemptParamsLike, CopilotAttemptDeps, @@ -58,13 +58,13 @@ export function prepareCopilotAttemptContext( const resolvedWorkspaceForSandbox = readResolvedAttemptPath(input.workspaceDir) ?? readResolvedAttemptPath(input.cwd); const sandboxSessionKey = - readString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ?? - readString((input as { sessionKey?: unknown }).sessionKey) ?? - readString(input.sessionId); + readNonEmptyString((input as { sandboxSessionKey?: unknown }).sandboxSessionKey) ?? + readNonEmptyString((input as { sessionKey?: unknown }).sessionKey) ?? + readNonEmptyString(input.sessionId); const { sessionAgentId } = resolveSessionAgentIds({ - sessionKey: readString((input as { sessionKey?: unknown }).sessionKey), + sessionKey: readNonEmptyString((input as { sessionKey?: unknown }).sessionKey), config: input.config, - agentId: readString(params.agentId), + agentId: readNonEmptyString(params.agentId), }); const hookContextWindowFields = { ...(input.contextWindowInfo?.tokens diff --git a/extensions/copilot/src/attempt-transcript-journal.ts b/extensions/copilot/src/attempt-transcript-journal.ts index bcdaa4785f07..2ed7134a523e 100644 --- a/extensions/copilot/src/attempt-transcript-journal.ts +++ b/extensions/copilot/src/attempt-transcript-journal.ts @@ -12,6 +12,7 @@ import { type SessionTranscriptTargetParams, type TranscriptEntryAnchor, } from "openclaw/plugin-sdk/session-transcript-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { AttemptParamsLike } from "./attempt-types.js"; type TranscriptMessage = Extract; @@ -606,9 +607,9 @@ function createPersistenceReceipt(): PersistenceReceipt { } function resolveTranscriptTarget(attempt: AttemptParamsLike): SessionTranscriptTargetParams { - const sessionId = readString(attempt.sessionTarget?.sessionId); - const sessionKey = readString(attempt.sessionTarget?.sessionKey); - const storePath = readString(attempt.sessionTarget?.storePath); + const sessionId = normalizeOptionalString(attempt.sessionTarget?.sessionId); + const sessionKey = normalizeOptionalString(attempt.sessionTarget?.sessionKey); + const storePath = normalizeOptionalString(attempt.sessionTarget?.storePath); if (!sessionId || !sessionKey || !storePath) { const error = new Error( "[copilot-attempt] canonical transcript persistence requires an exact runtime session target", @@ -616,7 +617,7 @@ function resolveTranscriptTarget(attempt: AttemptParamsLike): SessionTranscriptT error.code = "transcript_persistence_failed"; throw error; } - const agentId = readString(attempt.sessionTarget?.agentId ?? attempt.agentId); + const agentId = normalizeOptionalString(attempt.sessionTarget?.agentId ?? attempt.agentId); return { sessionId, sessionKey, storePath, ...(agentId ? { agentId } : {}) }; } @@ -738,7 +739,3 @@ function userText(content: unknown): string { } return JSON.stringify(content) ?? ""; } - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} diff --git a/extensions/copilot/src/attempt.ts b/extensions/copilot/src/attempt.ts index f4bdff32b53b..f3c151efd503 100644 --- a/extensions/copilot/src/attempt.ts +++ b/extensions/copilot/src/attempt.ts @@ -2,9 +2,9 @@ import { finalizeCopilotAttempt } from "./attempt-cleanup.js"; import { createPromptError, createResult, - readString, + readNonEmptyString, resolvePoolAcquire, - toError, + toCopilotError, } from "./attempt-config.js"; import { runCopilotExecution } from "./attempt-execution.js"; import { prepareCopilotAttemptContext } from "./attempt-prepare.js"; @@ -55,22 +55,22 @@ export async function runCopilotAttempt( try { resolveCopilotProvider({ model: modelRef, - resolvedApiKey: readString(params.resolvedApiKey), - authProfileId: readString(params.authProfileId), + resolvedApiKey: readNonEmptyString(params.resolvedApiKey), + authProfileId: readNonEmptyString(params.authProfileId), }); } catch (error) { return finishAttempt( createResult(input, { messagesSnapshot: messages, now, - promptError: createPromptError("model_not_supported", toError(error).message, error), + promptError: createPromptError("model_not_supported", toCopilotError(error).message, error), sdkSessionId: undefined, sessionIdUsed: input.sessionId, }), ); } const settledFinalizationSessionId = settledToolFinalization - ? readString(input.initialReplayState?.sdkSessionId) + ? readNonEmptyString(input.initialReplayState?.sdkSessionId) : undefined; if (settledToolFinalization && !settledFinalizationSessionId) { return finishAttempt( diff --git a/extensions/copilot/src/auth-bridge.ts b/extensions/copilot/src/auth-bridge.ts index 21e4c079c08f..b111e126de9b 100755 --- a/extensions/copilot/src/auth-bridge.ts +++ b/extensions/copilot/src/auth-bridge.ts @@ -149,18 +149,19 @@ export function resolveCopilotAuth(input: ResolveCopilotAuthInput): ResolvedCopi const agentId = sanitizeAgentId(input.agentId); const copilotHome = resolveCopilotHome({ - explicit: readString(input.copilotHome), - agentDir: readString(input.agentDir), - workspaceDir: readString(input.workspaceDir), + explicit: readNonEmptyString(input.copilotHome), + agentDir: readNonEmptyString(input.agentDir), + workspaceDir: readNonEmptyString(input.workspaceDir), agentId, env, homeDir, }); - const explicitToken = readString(input.auth?.gitHubToken); - const explicitProfileId = readString(input.auth?.profileId) ?? readString(input.authProfileId); + const explicitToken = readNonEmptyString(input.auth?.gitHubToken); + const explicitProfileId = + readNonEmptyString(input.auth?.profileId) ?? readNonEmptyString(input.authProfileId); const explicitProfileVersion = - readString(input.auth?.profileVersion) ?? readString(input.profileVersion); + readNonEmptyString(input.auth?.profileVersion) ?? readNonEmptyString(input.profileVersion); if (input.auth?.useLoggedInUser === true) { return { @@ -192,9 +193,9 @@ export function resolveCopilotAuth(input: ResolveCopilotAuthInput): ResolvedCopi // We synthesise the pool-key version from the token fingerprint so // rotation busts the cache cleanly (matching the env-fallback // strategy). The contract does not carry a separate `profileVersion`. - const contractToken = readString(input.resolvedApiKey); + const contractToken = readNonEmptyString(input.resolvedApiKey); if (contractToken) { - const contractProfileId = readString(input.authProfileId); + const contractProfileId = readNonEmptyString(input.authProfileId); return { authMode: "gitHubToken", gitHubToken: contractToken, @@ -268,7 +269,7 @@ function resolveCopilotHome(args: { return resolve(join(args.agentDir, "copilot")); } - const openClawHome = readString(args.env.OPENCLAW_HOME); + const openClawHome = readNonEmptyString(args.env.OPENCLAW_HOME); const rootHome = openClawHome ? resolve(openClawHome) : safeHomeDir(args.homeDir); // Per-agent isolation per proposal section 3.6: // /.openclaw/agents//copilot @@ -301,10 +302,10 @@ function readEnvTokenFallback( // agentRuntime.id: "copilot" gets the token they configured rather // than silently falling through to the logged-in CLI user. const candidates: Array<{ name: string; value: string | undefined }> = [ - { name: "OPENCLAW_GITHUB_TOKEN", value: readString(env.OPENCLAW_GITHUB_TOKEN) }, - { name: "COPILOT_GITHUB_TOKEN", value: readString(env.COPILOT_GITHUB_TOKEN) }, - { name: "GH_TOKEN", value: readString(env.GH_TOKEN) }, - { name: "GITHUB_TOKEN", value: readString(env.GITHUB_TOKEN) }, + { name: "OPENCLAW_GITHUB_TOKEN", value: readNonEmptyString(env.OPENCLAW_GITHUB_TOKEN) }, + { name: "COPILOT_GITHUB_TOKEN", value: readNonEmptyString(env.COPILOT_GITHUB_TOKEN) }, + { name: "GH_TOKEN", value: readNonEmptyString(env.GH_TOKEN) }, + { name: "GITHUB_TOKEN", value: readNonEmptyString(env.GITHUB_TOKEN) }, ]; for (const { name, value } of candidates) { if (value) { @@ -331,6 +332,6 @@ export function tokenFingerprint(token: string): string { return `sha256:${hex}`; } -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/extensions/copilot/src/event-bridge-transcript.ts b/extensions/copilot/src/event-bridge-transcript.ts index e363a78462b5..ffd0cc5d2158 100644 --- a/extensions/copilot/src/event-bridge-transcript.ts +++ b/extensions/copilot/src/event-bridge-transcript.ts @@ -78,7 +78,7 @@ export function resolveAssistantUsage( latest: AssistantUsageSnapshot | undefined, byApiCallId: Map, ): AssistantUsageSnapshot | undefined { - const apiCallId = readString(event?.data.apiCallId); + const apiCallId = readNonEmptyString(event?.data.apiCallId); return apiCallId ? (byApiCallId.get(apiCallId) ?? latest) : latest; } @@ -162,6 +162,6 @@ export function sanitizeToolDetailText(text: string): string { return typeof value === "string" ? value : ""; } -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/extensions/copilot/src/event-bridge.ts b/extensions/copilot/src/event-bridge.ts index 12fd007daf88..732973eea72b 100644 --- a/extensions/copilot/src/event-bridge.ts +++ b/extensions/copilot/src/event-bridge.ts @@ -177,7 +177,7 @@ export function attachEventBridge( if (!projection) { return; } - const source = readString(event.data.source); + const source = readNonEmptyString(event.data.source); const transformedContent = typeof event.data.transformedContent === "string" ? event.data.transformedContent : undefined; const openClawMeta = projectSdkUserMetadata(event.data.attachments, source); @@ -229,7 +229,7 @@ export function attachEventBridge( if (!isRootSessionEvent(event)) { return; } - const messageId = readString(event.data.messageId) ?? "assistant-message"; + const messageId = readNonEmptyString(event.data.messageId) ?? "assistant-message"; const delta = event.data.deltaContent; if (!delta) { return; @@ -266,7 +266,7 @@ export function attachEventBridge( if (!isRootSessionEvent(event)) { return; } - const reasoningId = readString(event.data.reasoningId) ?? "assistant-reasoning"; + const reasoningId = readNonEmptyString(event.data.reasoningId) ?? "assistant-reasoning"; const delta = event.data.deltaContent; if (!delta) { return; @@ -311,7 +311,7 @@ export function attachEventBridge( return; } usage = normalizeCopilotUsage(event.data); - const apiCallId = readString(event.data.apiCallId); + const apiCallId = readNonEmptyString(event.data.apiCallId); if (apiCallId && usage) { usageByApiCallId.set(apiCallId, usage); } @@ -664,7 +664,7 @@ export function attachEventBridge( transcriptAssistantTexts: [event.data.content ?? ""], ...(transcriptReasoningText ? { transcriptReasoningText } : {}), }; - const apiCallId = readString(event.data.apiCallId); + const apiCallId = readNonEmptyString(event.data.apiCallId); if (!apiCallId) { if (projectedAssistantMessageIdsWithoutApiCall.has(event.data.messageId)) { options.transcriptProjection?.journal.markReplayIncomplete(); @@ -969,7 +969,7 @@ function splitPlanText(text: string | undefined): string[] { .filter((line) => line.length > 0); } -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/extensions/copilot/src/provider-bridge.ts b/extensions/copilot/src/provider-bridge.ts index 4197d052213e..6236d8500f03 100644 --- a/extensions/copilot/src/provider-bridge.ts +++ b/extensions/copilot/src/provider-bridge.ts @@ -2,6 +2,7 @@ import type { ProviderConfig } from "@github/copilot-sdk"; import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth"; import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { tokenFingerprint } from "./auth-bridge.js"; const COPILOT_BYOK_PROVIDER_ERROR = @@ -83,7 +84,7 @@ export function resolveCopilotProvider(params: { return { mode: "github-copilot" }; } - const baseUrl = readString(params.model.baseUrl); + const baseUrl = normalizeOptionalString(params.model.baseUrl); if (!baseUrl) { throw new Error(COPILOT_BYOK_PROVIDER_ERROR); } @@ -92,11 +93,11 @@ export function resolveCopilotProvider(params: { throw new Error(COPILOT_BYOK_TRANSPORT_POLICY_ERROR); } - const api = readString(params.model.api)?.toLowerCase() ?? "openai-responses"; + const api = normalizeOptionalString(params.model.api)?.toLowerCase() ?? "openai-responses"; const provider = resolveProviderType(api, baseUrl, params.model.azureApiVersion); const resolvedApiKey = resolveProviderCredential(params.resolvedApiKey); const headers = resolveProviderHeaders(params.model.headers); - const requestAuthMode = readString(params.model.requestAuthMode)?.toLowerCase(); + const requestAuthMode = normalizeOptionalString(params.model.requestAuthMode)?.toLowerCase(); const usePreparedRequestAuth = requestAuthMode !== undefined && requestAuthMode !== "provider-default"; const providerConfig: ProviderConfig = { @@ -156,16 +157,16 @@ export function supportsCopilotByokProviderShape( "api" | "baseUrl" | "requestProxy" | "requestTls" | "requestAllowPrivateNetwork" >, ): boolean { - if (!readString(model.baseUrl) || hasUnsupportedTransportPolicy(model)) { + if (!normalizeOptionalString(model.baseUrl) || hasUnsupportedTransportPolicy(model)) { return false; } try { resolveProviderType( - readString(model.api)?.toLowerCase() ?? "openai-responses", - readString(model.baseUrl)!, + normalizeOptionalString(model.api)?.toLowerCase() ?? "openai-responses", + normalizeOptionalString(model.baseUrl)!, undefined, ); - assertByokEndpointHostAllowed(readString(model.baseUrl)!); + assertByokEndpointHostAllowed(normalizeOptionalString(model.baseUrl)!); return true; } catch { return false; @@ -274,7 +275,7 @@ function resolveAzureProviderType( url.pathname = ""; url.search = ""; url.hash = ""; - const resolvedApiVersion = readString(apiVersion); + const resolvedApiVersion = normalizeOptionalString(apiVersion); return { type: "azure", wireApi: "responses", @@ -317,12 +318,8 @@ function stableSerialize(value: unknown): string { return JSON.stringify(value) ?? "null"; } -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function resolveProviderCredential(value: string | undefined): string | undefined { - const credential = readString(value); + const credential = normalizeOptionalString(value); return credential && !isNonSecretApiKeyMarker(credential) ? credential : undefined; } diff --git a/extensions/copilot/src/runtime.ts b/extensions/copilot/src/runtime.ts index de5415cfc4c7..150f8dc2cb8a 100644 --- a/extensions/copilot/src/runtime.ts +++ b/extensions/copilot/src/runtime.ts @@ -118,7 +118,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): try { return await client.stop(); } catch (error: unknown) { - return [toError(error)]; + return [toCopilotRuntimeError(error)]; } finally { entry.state = { kind: "stopped" }; maybeDeleteEntry(entry); @@ -136,7 +136,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): await entry.state.promise; } catch (error: unknown) { maybeDeleteEntry(entry); - return [toError(error)]; + return [toCopilotRuntimeError(error)]; } return stopEntry(entry); } @@ -187,7 +187,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): } catch (error: unknown) { entry.state = { kind: "stopped" }; maybeDeleteEntry(entry); - throw toError(error); + throw toCopilotRuntimeError(error); } })(); @@ -220,7 +220,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): } return { key: created.entry.key, client }; } catch (error: unknown) { - throw toError(error); + throw toCopilotRuntimeError(error); } } @@ -235,7 +235,7 @@ export function createCopilotClientPool(options: CopilotClientPoolOptions = {}): } return { key: existing.key, client }; } catch (error: unknown) { - throw toError(error); + throw toCopilotRuntimeError(error); } } case "ready": @@ -390,7 +390,7 @@ function normalizeCopilotHome(copilotHome: string): string { return normalizedHome; } -function toError(error: unknown): Error { +function toCopilotRuntimeError(error: unknown): Error { if (error instanceof Error) { return error; } diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index fe93566a6679..d2a55d5b2bc9 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -254,7 +254,7 @@ export async function createCopilotToolBridge( } } catch (error: unknown) { throw createError( - `[copilot-tool-bridge] createOpenClawCodingTools failed: ${toError(error).message}`, + `[copilot-tool-bridge] createOpenClawCodingTools failed: ${toCopilotToolError(error).message}`, error, ); } @@ -547,7 +547,7 @@ function convertOpenClawToolToSdkTool( error: unknown, executionStarted: boolean, ): ToolResultObject => { - const errorMessage = toError(error).message; + const errorMessage = toCopilotToolError(error).message; ctx.observeToolTerminal?.({ toolCallId: invocation.toolCallId, toolName: sourceTool.name, @@ -595,7 +595,7 @@ function convertOpenClawToolToSdkTool( args, invocation, startedAt, - `[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toError(error).message}`, + `[copilot-tool-bridge] beforeExecute failed for tool '${sourceTool.name}': ${toCopilotToolError(error).message}`, error, false, ); @@ -609,7 +609,7 @@ function convertOpenClawToolToSdkTool( args, invocation, startedAt, - `[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toError(error).message}`, + `[copilot-tool-bridge] prepareArguments failed for tool '${sourceTool.name}': ${toCopilotToolError(error).message}`, error, false, ); @@ -628,7 +628,7 @@ function convertOpenClawToolToSdkTool( preparedArgs, invocation, startedAt, - `[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toError(error).message}`, + `[copilot-tool-bridge] tool '${sourceTool.name}' failed: ${toCopilotToolError(error).message}`, error, true, ); @@ -756,7 +756,7 @@ async function executeCatalogTool( }); return result; } catch (error: unknown) { - const message = toError(error).message; + const message = toCopilotToolError(error).message; // Completion hooks can throw after the tool terminal outcome. Do not // rewrite that recorded outcome as a second, contradictory tool failure. if (!terminalObserved) { @@ -801,7 +801,7 @@ function createFailureResult(message: string, error: unknown): ToolResultObject // Error object would produce a non-serializable JSON-RPC payload, so we // surface the message string instead. return { - error: toError(error).message, + error: toCopilotToolError(error).message, resultType: "failure", textResultForLlm: message, }; @@ -912,6 +912,6 @@ function findDuplicateToolNames(sourceTools: AnyAgentTool[]): string[] { .toSorted(); } -function toError(error: unknown): Error { +function toCopilotToolError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/extensions/deepgram/audio.ts b/extensions/deepgram/audio.ts index 4994f1dfa093..660a58eac0b3 100644 --- a/extensions/deepgram/audio.ts +++ b/extensions/deepgram/audio.ts @@ -10,7 +10,7 @@ import { resolveProviderHttpRequestConfig, requireTranscriptionText, } from "openclaw/plugin-sdk/provider-http"; -import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export const DEFAULT_DEEPGRAM_AUDIO_BASE_URL = "https://api.deepgram.com/v1"; export const DEFAULT_DEEPGRAM_AUDIO_MODEL = "nova-3"; @@ -21,21 +21,21 @@ function resolveModel(model?: string): string { } function readDeepgramTranscript(payload: Record): string | undefined { - const results = asRecord(payload.results); + const results = asOptionalRecord(payload.results); if (!results) { return undefined; } if (!Array.isArray(results.channels)) { throw new Error("Audio transcription failed: malformed JSON response"); } - const channel = asRecord(results.channels[0]); + const channel = asOptionalRecord(results.channels[0]); if (!channel) { return undefined; } if (!Array.isArray(channel.alternatives)) { throw new Error("Audio transcription failed: malformed JSON response"); } - const alternative = asRecord(channel.alternatives[0]); + const alternative = asOptionalRecord(channel.alternatives[0]); if (!alternative) { return undefined; } diff --git a/extensions/deepinfra/speech-provider.ts b/extensions/deepinfra/speech-provider.ts index 22d0522dd002..d1982e3e72a3 100644 --- a/extensions/deepinfra/speech-provider.ts +++ b/extensions/deepinfra/speech-provider.ts @@ -1,9 +1,9 @@ // Deepinfra provider module implements model/runtime integration. import { - asObject, createOpenAiCompatibleSpeechProvider, type SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEEPINFRA_BASE_URL, DEEPINFRA_TTS_FALLBACK_MODELS, @@ -44,7 +44,7 @@ export function buildDeepInfraSpeechProvider(options?: { normalizeModel: normalizeDeepInfraModelRef, apiErrorLabel: "DeepInfra TTS API error", missingApiKeyError: "DeepInfra API key missing", - readExtraConfig: (raw) => ({ extraBody: asObject(raw?.extraBody) }), + readExtraConfig: (raw) => ({ extraBody: asOptionalRecord(raw?.extraBody) }), extraJsonBodyFields: [{ configKey: "extraBody", requestKey: "extra_body" }], }); } diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 74eb00171a49..f7cd9c8a2bd5 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -651,13 +651,13 @@ function createPdfScreenshotter( return { screenshotHtml }; } -function isRecord(value: unknown): value is Record { +function isObjectValue(value: unknown): value is Record { return typeof value === "object" && value !== null; } function readDetails(result: unknown): Record { const details = (result as { details?: unknown } | null | undefined)?.details; - if (!isRecord(details)) { + if (!isObjectValue(details)) { throw new Error("expected diffs tool result details"); } return details; @@ -688,7 +688,7 @@ function extractViewerArtifactToken(viewerPath: string): string { } function readParametersProperties(parameters: unknown): Record { - if (isRecord(parameters) && isRecord(parameters.properties)) { + if (isObjectValue(parameters) && isObjectValue(parameters.properties)) { return parameters.properties; } throw new Error("expected diffs tool parameter properties"); diff --git a/extensions/diffs/src/viewer-payload.ts b/extensions/diffs/src/viewer-payload.ts index 3cebf4c684c3..3b2dee7d3f32 100644 --- a/extensions/diffs/src/viewer-payload.ts +++ b/extensions/diffs/src/viewer-payload.ts @@ -4,7 +4,7 @@ import type { DiffViewerPayload } from "./types.js"; const OVERFLOW_VALUES = ["scroll", "wrap"] as const; -function isRecord(value: unknown): value is Record { +function isViewerRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -24,7 +24,7 @@ export function parseViewerPayloadJson(raw: string): DiffViewerPayload { } function isDiffViewerPayload(value: unknown): value is DiffViewerPayload { - if (!isRecord(value)) { + if (!isViewerRecord(value)) { return false; } @@ -40,8 +40,8 @@ function isDiffViewerPayload(value: unknown): value is DiffViewerPayload { return false; } - const hasFileDiff = isRecord(value.fileDiff); - const hasBeforeAfterFiles = isRecord(value.oldFile) && isRecord(value.newFile); + const hasFileDiff = isViewerRecord(value.fileDiff); + const hasBeforeAfterFiles = isViewerRecord(value.oldFile) && isViewerRecord(value.newFile); if (!hasFileDiff && !hasBeforeAfterFiles) { return false; } @@ -50,11 +50,11 @@ function isDiffViewerPayload(value: unknown): value is DiffViewerPayload { } function isViewerOptions(value: unknown): boolean { - if (!isRecord(value)) { + if (!isViewerRecord(value)) { return false; } - if (!isRecord(value.theme)) { + if (!isViewerRecord(value.theme)) { return false; } if (value.theme.light !== "pierre-light" || value.theme.dark !== "pierre-dark") { diff --git a/extensions/discord/src/components.parse.ts b/extensions/discord/src/components.parse.ts index 44b94ab46fdc..2e4f03199c21 100644 --- a/extensions/discord/src/components.parse.ts +++ b/extensions/discord/src/components.parse.ts @@ -1,6 +1,9 @@ // Discord plugin module implements components.parse behavior. import { ButtonStyle, TextInputStyle } from "discord-api-types/v10"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import type { DiscordComponentBlock, DiscordComponentButtonSpec, @@ -32,7 +35,11 @@ function requireObject(value: unknown, label: string): Record { return value as Record; } -function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string { +function readRequiredString( + value: unknown, + label: string, + opts?: { allowEmpty?: boolean }, +): string { if (typeof value !== "string") { throw new Error(`${label} must be a string`); } @@ -43,19 +50,11 @@ function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean return opts?.allowEmpty ? value : trimmed; } -function readOptionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function readOptionalCallbackDataKind( value: unknown, label: string, ): DiscordComponentCallbackDataKind | undefined { - const kind = readOptionalString(value); + const kind = normalizeOptionalString(value); if (kind === undefined) { return undefined; } @@ -75,7 +74,7 @@ function readOptionalStringArray(value: unknown, label: string): string[] | unde if (value.length === 0) { return undefined; } - return value.map((entry, index) => readString(entry, `${label}[${index}]`)); + return value.map((entry, index) => readRequiredString(entry, `${label}[${index}]`)); } function readOptionalInteger( @@ -104,8 +103,8 @@ function readOptionalEmoji(value: unknown, label: string) { } const obj = value as { name?: unknown; id?: unknown; animated?: unknown }; return { - name: readString(obj.name, `${label}.name`), - id: readOptionalString(obj.id), + name: readRequiredString(obj.name, `${label}.name`), + id: normalizeOptionalString(obj.id), animated: typeof obj.animated === "boolean" ? obj.animated : undefined, }; } @@ -181,9 +180,9 @@ function parseSelectOptions( return raw.map((entry, index) => { const obj = requireObject(entry, `${label}[${index}]`); return { - label: readString(obj.label, `${label}[${index}].label`), - value: readString(obj.value, `${label}[${index}].value`), - description: readOptionalString(obj.description), + label: readRequiredString(obj.label, `${label}[${index}].label`), + value: readRequiredString(obj.value, `${label}[${index}].value`), + description: normalizeOptionalString(obj.description), emoji: readOptionalEmoji(obj.emoji, `${label}[${index}].emoji`), default: typeof obj.default === "boolean" ? obj.default : undefined, }; @@ -192,16 +191,16 @@ function parseSelectOptions( function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec { const obj = requireObject(raw, label); - const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined; - const url = readOptionalString(obj.url); + const style = normalizeOptionalString(obj.style) as DiscordComponentButtonStyle | undefined; + const url = normalizeOptionalString(obj.url); if ((style === "link" || url) && !url) { throw new Error(`${label}.url is required for link buttons`); } return { - label: readString(obj.label, `${label}.label`), + label: readRequiredString(obj.label, `${label}.label`), style, url, - callbackData: readOptionalString(obj.callbackData), + callbackData: normalizeOptionalString(obj.callbackData), callbackDataKind: readOptionalCallbackDataKind( obj.callbackDataKind, `${label}.callbackDataKind`, @@ -214,7 +213,7 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec { const obj = requireObject(raw, label); - const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined; + const type = normalizeOptionalString(obj.type) as DiscordComponentSelectType | undefined; const allowedTypes: DiscordComponentSelectType[] = [ "string", "user", @@ -227,12 +226,12 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe } return { type, - callbackData: readOptionalString(obj.callbackData), + callbackData: normalizeOptionalString(obj.callbackData), callbackDataKind: readOptionalCallbackDataKind( obj.callbackDataKind, `${label}.callbackDataKind`, ), - placeholder: readOptionalString(obj.placeholder), + placeholder: normalizeOptionalString(obj.placeholder), minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { min: 0, max: 25 }), maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, { min: 1, max: 25 }), options: parseSelectOptions(obj.options, `${label}.options`), @@ -243,7 +242,7 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec { const obj = requireObject(raw, label); const type = normalizeLowercaseStringOrEmpty( - readString(obj.type, `${label}.type`), + readRequiredString(obj.type, `${label}.type`), ) as DiscordComponentModalFieldType; const supported: DiscordComponentModalFieldType[] = [ "text", @@ -267,10 +266,10 @@ function parseModalField(raw: unknown, label: string, index: number): DiscordMod const maxValues = type === "checkbox" ? 10 : 25; return { type, - name: normalizeModalFieldName(readOptionalString(obj.name), index), - label: readString(obj.label, `${label}.label`), - description: readOptionalString(obj.description), - placeholder: readOptionalString(obj.placeholder), + name: normalizeModalFieldName(normalizeOptionalString(obj.name), index), + label: readRequiredString(obj.label, `${label}.label`), + description: normalizeOptionalString(obj.description), + placeholder: normalizeOptionalString(obj.placeholder), required, options, minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { @@ -283,25 +282,25 @@ function parseModalField(raw: unknown, label: string, index: number): DiscordMod }), minLength: readOptionalInteger(obj.minLength, `${label}.minLength`, { min: 0, max: 4000 }), maxLength: readOptionalInteger(obj.maxLength, `${label}.maxLength`, { min: 1, max: 4000 }), - style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"], + style: normalizeOptionalString(obj.style) as DiscordModalFieldSpec["style"], }; } function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock { const obj = requireObject(raw, label); - const typeRaw = normalizeLowercaseStringOrEmpty(readString(obj.type, `${label}.type`)); + const typeRaw = normalizeLowercaseStringOrEmpty(readRequiredString(obj.type, `${label}.type`)); const type = normalizeBlockType(typeRaw); switch (type) { case "text": return { type: "text", - text: readString(obj.text, `${label}.text`), + text: readRequiredString(obj.text, `${label}.text`), }; case "section": { - const text = readOptionalString(obj.text); + const text = normalizeOptionalString(obj.text); const textsRaw = obj.texts; const texts = Array.isArray(textsRaw) - ? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`)) + ? textsRaw.map((entry, idx) => readRequiredString(entry, `${label}.texts[${idx}]`)) : undefined; if (!text && (!texts || texts.length === 0)) { throw new Error(`${label}.text or ${label}.texts is required for section blocks`); @@ -310,12 +309,12 @@ function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock if (obj.accessory !== undefined) { const accessoryObj = requireObject(obj.accessory, `${label}.accessory`); const accessoryType = normalizeLowercaseStringOrEmpty( - readString(accessoryObj.type, `${label}.accessory.type`), + readRequiredString(accessoryObj.type, `${label}.accessory.type`), ); if (accessoryType === "thumbnail") { accessory = { type: "thumbnail", - url: readString(accessoryObj.url, `${label}.accessory.url`), + url: readRequiredString(accessoryObj.url, `${label}.accessory.url`), }; } else if (accessoryType === "button") { accessory = { @@ -376,8 +375,8 @@ function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock const items = itemsRaw.map((entry, idx) => { const itemObj = requireObject(entry, `${label}.items[${idx}]`); return { - url: readString(itemObj.url, `${label}.items[${idx}].url`), - description: readOptionalString(itemObj.description), + url: readRequiredString(itemObj.url, `${label}.items[${idx}].url`), + description: normalizeOptionalString(itemObj.description), spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined, }; }); @@ -387,7 +386,7 @@ function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock }; } case "file": { - const file = readString(obj.file, `${label}.file`); + const file = readRequiredString(obj.file, `${label}.file`); return { type: "file", file: normalizeAttachmentRef(file, `${label}.file`), @@ -424,16 +423,16 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS parseModalField(entry, `components.modal.fields[${idx}]`, idx), ); modal = { - title: readString(modalObj.title, "components.modal.title"), - callbackData: readOptionalString(modalObj.callbackData), - triggerLabel: readOptionalString(modalObj.triggerLabel), - triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle, + title: readRequiredString(modalObj.title, "components.modal.title"), + callbackData: normalizeOptionalString(modalObj.callbackData), + triggerLabel: normalizeOptionalString(modalObj.triggerLabel), + triggerStyle: normalizeOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle, allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"), fields, }; } return { - text: readOptionalString(obj.text), + text: normalizeOptionalString(obj.text), reusable, container: typeof obj.container === "object" && obj.container && !Array.isArray(obj.container) diff --git a/extensions/discord/src/inbound-event-delivery.ts b/extensions/discord/src/inbound-event-delivery.ts index 718d7ba40ab3..8dd809b31c44 100644 --- a/extensions/discord/src/inbound-event-delivery.ts +++ b/extensions/discord/src/inbound-event-delivery.ts @@ -2,8 +2,8 @@ import { createInboundEventDeliveryCorrelation } from "openclaw/plugin-sdk/inbound-event-delivery"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload"; import { - asOptionalRecord as readRecord, - normalizeOptionalString as readString, + asOptionalRecord, + normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; const DISCORD_INBOUND_EVENT_DELIVERY_KEY = "__openclawInboundEventDelivery"; @@ -32,8 +32,8 @@ export function withDiscordInboundEventDeliveryMetadata( if (!sessionKey || params.inboundEventKind !== "room_event") { return payload; } - const channelData = readRecord(payload.channelData) ?? {}; - const discordData = readRecord(channelData.discord) ?? {}; + const channelData = asOptionalRecord(payload.channelData) ?? {}; + const discordData = asOptionalRecord(channelData.discord) ?? {}; return { ...payload, channelData: { @@ -54,15 +54,15 @@ export function notifyDiscordInboundEventOutboundPayloadSuccess(params: { to: string; accountId?: string | null; }): void { - const channelData = readRecord(params.payload.channelData); - const discordData = readRecord(channelData?.discord); - const metadata = readRecord(discordData?.[DISCORD_INBOUND_EVENT_DELIVERY_KEY]); + const channelData = asOptionalRecord(params.payload.channelData); + const discordData = asOptionalRecord(channelData?.discord); + const metadata = asOptionalRecord(discordData?.[DISCORD_INBOUND_EVENT_DELIVERY_KEY]); if (!metadata) { return; } discordInboundEventDelivery.notify({ - sessionKey: readString(metadata.sessionKey), - inboundEventKind: readString(metadata.inboundEventKind), + sessionKey: normalizeOptionalString(metadata.sessionKey), + inboundEventKind: normalizeOptionalString(metadata.inboundEventKind), to: params.to, accountId: params.accountId, }); diff --git a/extensions/discord/src/monitor/message-handler.process.test-harness.ts b/extensions/discord/src/monitor/message-handler.process.test-harness.ts index a9470ee8c68d..351655d6520c 100644 --- a/extensions/discord/src/monitor/message-handler.process.test-harness.ts +++ b/extensions/discord/src/monitor/message-handler.process.test-harness.ts @@ -415,7 +415,13 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { cfg, dispatcherOptions: { ...plan.dispatcherOptions, - deliver: (delivery.deliverWithProviderMessageSending ?? delivery.deliver)!, + deliver: (payload, info) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; + return delivery.deliverWithProviderMessageSending(payload, providerInfo); + }, onError: delivery.onError, }, toolsAllow: plan.toolsAllow, diff --git a/extensions/discord/src/monitor/model-picker-preference-primitives.ts b/extensions/discord/src/monitor/model-picker-preference-primitives.ts index aaa4e8ed7ac8..70493d722dbf 100644 --- a/extensions/discord/src/monitor/model-picker-preference-primitives.ts +++ b/extensions/discord/src/monitor/model-picker-preference-primitives.ts @@ -43,7 +43,7 @@ export function buildPreferenceModelKey(scopeKey: string, modelRef: string): str return `v1:${hashSegment(scopeKey, 32)}:${hashSegment(modelRef, 24)}`; } -export function timestampMs(value: unknown): number { +export function preferenceTimestampMs(value: unknown): number { const parsed = typeof value === "string" ? Date.parse(value) : Number.NaN; return Number.isFinite(parsed) ? parsed : 0; } diff --git a/extensions/discord/src/monitor/model-picker-preferences-migrations.ts b/extensions/discord/src/monitor/model-picker-preferences-migrations.ts index 24f6b37ddc96..f67a27abbd0d 100644 --- a/extensions/discord/src/monitor/model-picker-preferences-migrations.ts +++ b/extensions/discord/src/monitor/model-picker-preferences-migrations.ts @@ -10,8 +10,8 @@ import { } from "../command-deploy-store.js"; import { buildPreferenceModelKey, + preferenceTimestampMs, sanitizeRecentModels, - timestampMs, } from "./model-picker-preference-primitives.js"; import { normalizePersistedBinding, @@ -74,7 +74,7 @@ function normalizeLegacyPreferenceKey(key: string): string | undefined { } function legacyUpdatedAtForIndex(updatedAt: unknown, index: number, total: number): string { - const baseMs = timestampMs(updatedAt); + const baseMs = preferenceTimestampMs(updatedAt); const anchorMs = Math.min(baseMs + Math.max(0, total), MAX_DATE_TIMESTAMP_MS); const shiftedMs = anchorMs - Math.max(0, index); return ( diff --git a/extensions/discord/src/monitor/model-picker-preferences.ts b/extensions/discord/src/monitor/model-picker-preferences.ts index fde339bfac85..9fe9d4aeb424 100644 --- a/extensions/discord/src/monitor/model-picker-preferences.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.ts @@ -10,8 +10,8 @@ import { getDiscordRuntime } from "../runtime.js"; import { buildPreferenceModelKey, normalizeModelRef, + preferenceTimestampMs, sanitizeRecentModels, - timestampMs, } from "./model-picker-preference-primitives.js"; const DEFAULT_RECENT_LIMIT = 5; @@ -96,7 +96,7 @@ function comparePreferenceEntries( right: { key: string; value: ModelPickerPreferencesEntry }, ): number { return ( - timestampMs(right.value.updatedAt) - timestampMs(left.value.updatedAt) || + preferenceTimestampMs(right.value.updatedAt) - preferenceTimestampMs(left.value.updatedAt) || timestampOrder(right.value.updatedOrder) - timestampOrder(left.value.updatedOrder) || left.key.localeCompare(right.key) ); @@ -107,7 +107,7 @@ function nextPreferenceTimestamp(existingEntries: ModelPickerPreferencesEntry[]) updatedOrder: number; } { const existingMaxTimestampMs = existingEntries.reduce( - (max, entry) => Math.max(max, timestampMs(entry.updatedAt)), + (max, entry) => Math.max(max, preferenceTimestampMs(entry.updatedAt)), 0, ); lastPreferenceTimestampMs = Math.min( diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 42cf73e89b7c..4502f8bcfb85 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -228,13 +228,13 @@ type MockCalls = { mock: { calls: unknown[][] }; }; -function isRecord(value: unknown): value is Record { +function isObjectValue(value: unknown): value is Record { return typeof value === "object" && value !== null; } function requireRecord(value: unknown, label: string): Record { - expect(isRecord(value), `${label} should be an object`).toBe(true); - if (!isRecord(value)) { + expect(isObjectValue(value), `${label} should be an object`).toBe(true); + if (!isObjectValue(value)) { throw new Error(`${label} should be an object`); } return value; @@ -292,7 +292,7 @@ function expectFollowUpFields( function expectNoFollowUpContent(interaction: MockCommandInteraction, content: string) { const calls = (interaction.followUp as unknown as MockCalls).mock.calls; - const matched = calls.some(([payload]) => isRecord(payload) && payload.content === content); + const matched = calls.some(([payload]) => isObjectValue(payload) && payload.content === content); expect(matched).toBe(false); } @@ -1896,7 +1896,7 @@ describe("Discord native plugin command dispatch", () => { const replyCalls = (interaction.reply as unknown as MockCalls).mock.calls; const blockedReply = replyCalls.some( ([payload]) => - isRecord(payload) && + isObjectValue(payload) && payload.content === "Configured ACP binding is unavailable right now. Please try again.", ); expect(blockedReply).toBe(false); diff --git a/extensions/discord/src/monitor/thread-bindings.session-adapter.ts b/extensions/discord/src/monitor/thread-bindings.session-adapter.ts index 5514884214f2..31da7ab14b11 100644 --- a/extensions/discord/src/monitor/thread-bindings.session-adapter.ts +++ b/extensions/discord/src/monitor/thread-bindings.session-adapter.ts @@ -115,7 +115,8 @@ export function createThreadBindingSessionAdapter(params: { resolveCurrentCfg: () => OpenClawConfig; resolveCurrentToken: () => string | undefined; }): SessionBindingAdapter { - const toRecord = (entry: ThreadBindingRecord) => toSessionBindingRecord(entry, params.defaults); + const serializeBinding = (entry: ThreadBindingRecord) => + toSessionBindingRecord(entry, params.defaults); return { channel: "discord", @@ -184,16 +185,16 @@ export function createThreadBindingSessionAdapter(params: { introText, metadata, }); - return bound ? toRecord(bound) : null; + return bound ? serializeBinding(bound) : null; }, listBySession: (targetSessionKey) => - params.manager.listBySessionKey(targetSessionKey).map(toRecord), + params.manager.listBySessionKey(targetSessionKey).map(serializeBinding), resolveByConversation: (ref) => { if (ref.channel !== "discord") { return null; } const binding = params.manager.getByThreadId(ref.conversationId); - return binding ? toRecord(binding) : null; + return binding ? serializeBinding(binding) : null; }, touch: (bindingId, at) => { const threadId = resolveThreadBindingConversationIdFromBindingId({ @@ -211,7 +212,7 @@ export function createThreadBindingSessionAdapter(params: { targetSessionKey: input.targetSessionKey, reason: input.reason, }); - return removed.map(toRecord); + return removed.map(serializeBinding); } const threadId = resolveThreadBindingConversationIdFromBindingId({ accountId: params.accountId, @@ -224,7 +225,7 @@ export function createThreadBindingSessionAdapter(params: { threadId, reason: input.reason, }); - return removed ? [toRecord(removed)] : []; + return removed ? [serializeBinding(removed)] : []; }, }; } diff --git a/extensions/discord/src/status-issues.ts b/extensions/discord/src/status-issues.ts index f12f93f08a79..7d3342305680 100644 --- a/extensions/discord/src/status-issues.ts +++ b/extensions/discord/src/status-issues.ts @@ -5,11 +5,11 @@ import type { } from "openclaw/plugin-sdk/channel-contract"; import { appendMatchMetadata, - asString, isRecord, readAccountStatusSnapshot, resolveEnabledConfiguredAccountId, } from "openclaw/plugin-sdk/status-helpers"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; type DiscordIntentSummary = { messageContent?: "enabled" | "limited" | "disabled"; @@ -66,17 +66,17 @@ function readDiscordPermissionsAuditSummary(value: unknown): DiscordPermissionsA if (!isRecord(entry)) { return null; } - const channelId = asString(entry.channelId); + const channelId = normalizeOptionalString(entry.channelId); if (!channelId) { return null; } const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; const missing = Array.isArray(entry.missing) - ? entry.missing.map((v) => asString(v)).filter(Boolean) + ? entry.missing.map((v) => normalizeOptionalString(v)).filter(Boolean) : undefined; - const error = asString(entry.error) ?? null; - const matchKey = asString(entry.matchKey) ?? undefined; - const matchSource = asString(entry.matchSource) ?? undefined; + const error = normalizeOptionalString(entry.error) ?? null; + const matchKey = normalizeOptionalString(entry.matchKey); + const matchSource = normalizeOptionalString(entry.matchSource); return { channelId, ok, diff --git a/extensions/elevenlabs/speech-provider.ts b/extensions/elevenlabs/speech-provider.ts index a048b9da7e71..7509dc6e43d8 100644 --- a/extensions/elevenlabs/speech-provider.ts +++ b/extensions/elevenlabs/speech-provider.ts @@ -17,7 +17,6 @@ import type { import { asBoolean, asFiniteNumber, - asObject, normalizeApplyTextNormalization, normalizeLanguageCode, normalizeSeed, @@ -30,6 +29,7 @@ import { ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; import { + asOptionalRecord, normalizeLowercaseStringOrEmpty, parseBooleanValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -134,9 +134,9 @@ function normalizeVoiceSettings( function normalizeElevenLabsProviderConfig( rawConfig: Record, ): ElevenLabsProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.elevenlabs) ?? asObject(rawConfig.elevenlabs); - const rawVoiceSettings = asObject(raw?.voiceSettings); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.elevenlabs) ?? asOptionalRecord(rawConfig.elevenlabs); + const rawVoiceSettings = asOptionalRecord(raw?.voiceSettings); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, @@ -162,7 +162,7 @@ function normalizeElevenLabsProviderConfig( function readElevenLabsProviderConfig(config: SpeechProviderConfig): ElevenLabsProviderConfig { const defaults = normalizeElevenLabsProviderConfig({}); - const voiceSettings = asObject(config.voiceSettings); + const voiceSettings = asOptionalRecord(config.voiceSettings); return { apiKey: trimToUndefined(config.apiKey) ?? defaults.apiKey, baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(config.baseUrl) ?? defaults.baseUrl), @@ -205,7 +205,7 @@ function mergeVoiceSettingsOverride( return { ...ctx.currentOverrides, voiceSettings: { - ...asObject(ctx.currentOverrides?.voiceSettings), + ...asOptionalRecord(ctx.currentOverrides?.voiceSettings), ...next, }, }; @@ -215,7 +215,7 @@ function resolveVoiceSettingsOverride( base: ElevenLabsProviderConfig["voiceSettings"], overrides: unknown, ): ElevenLabsProviderConfig["voiceSettings"] { - const voiceSettings = asObject(overrides); + const voiceSettings = asOptionalRecord(overrides); return { ...base, ...normalizeVoiceSettings(voiceSettings), @@ -450,7 +450,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin { parseDirectiveToken, resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => { const base = normalizeElevenLabsProviderConfig(baseTtsConfig); - const talkVoiceSettings = asObject(talkProviderConfig.voiceSettings); + const talkVoiceSettings = asOptionalRecord(talkProviderConfig.voiceSettings); const resolvedTalkApiKey = resolveElevenLabsTalkApiKey(talkProviderConfig); return { ...base, diff --git a/extensions/feishu/src/accounts.ts b/extensions/feishu/src/accounts.ts index bf3fa4ade4ed..13b7e79e0b22 100644 --- a/extensions/feishu/src/accounts.ts +++ b/extensions/feishu/src/accounts.ts @@ -8,7 +8,7 @@ import { normalizeOptionalAccountId, } from "openclaw/plugin-sdk/account-resolution"; import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth"; -import { normalizeString } from "./comment-shared.js"; +import { normalizeOptionalString as normalizeString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { FeishuConfig, FeishuAccountConfig, diff --git a/extensions/feishu/src/card-interaction.ts b/extensions/feishu/src/card-interaction.ts index 67d9726b7542..ae12050f7df5 100644 --- a/extensions/feishu/src/card-interaction.ts +++ b/extensions/feishu/src/card-interaction.ts @@ -1,5 +1,5 @@ // Feishu plugin module implements card interaction behavior. -import { isRecord } from "./comment-shared.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export const FEISHU_CARD_INTERACTION_VERSION = "ocf1"; diff --git a/extensions/feishu/src/card-test-helpers.ts b/extensions/feishu/src/card-test-helpers.ts index 2b10cf2768f5..665d347b1243 100644 --- a/extensions/feishu/src/card-test-helpers.ts +++ b/extensions/feishu/src/card-test-helpers.ts @@ -1,3 +1,4 @@ +import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; // Feishu helper module supports card test helpers behavior. import { expect } from "vitest"; @@ -5,10 +6,6 @@ type MockCalls = { mock: { calls: unknown[][] }; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } @@ -36,17 +33,17 @@ export function expectFirstSentCardUsesFillWidthOnly(sendCardMock: { export function expectSentCardHasP2pAction(sendCardMock: MockCalls) { const hasP2pAction = sendCardMock.mock.calls.some(([arg]) => { - const card = asRecord(asRecord(arg)?.card); - const body = asRecord(card?.body); + const card = asOptionalObjectRecord(asOptionalObjectRecord(arg)?.card); + const body = asOptionalObjectRecord(card?.body); return asArray(body?.elements).some((element) => { - const elementRecord = asRecord(element); + const elementRecord = asOptionalObjectRecord(element); if (elementRecord?.tag !== "action") { return false; } return asArray(elementRecord.actions).some((action) => { - const actionRecord = asRecord(action); - const value = asRecord(actionRecord?.value); - const command = asRecord(value?.c); + const actionRecord = asOptionalObjectRecord(action); + const value = asOptionalObjectRecord(actionRecord?.value); + const command = asOptionalObjectRecord(value?.c); return command?.t === "p2p"; }); }); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 9b174b762f56..a88fc4874fbf 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -40,6 +40,7 @@ import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers"; import { + isRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -69,7 +70,6 @@ import { PAIRING_APPROVED_MESSAGE, } from "./channel-runtime-api.js"; import { normalizeFeishuChatType, resolveFeishuChatType } from "./chat-type.js"; -import { isRecord } from "./comment-shared.js"; import { FeishuChannelConfigSchema } from "./config-schema.js"; import { buildFeishuConversationId, diff --git a/extensions/feishu/src/client.test.ts b/extensions/feishu/src/client.test.ts index 9be99fc6aff8..00f41732a10a 100644 --- a/extensions/feishu/src/client.test.ts +++ b/extensions/feishu/src/client.test.ts @@ -141,7 +141,7 @@ const baseAccount: ResolvedFeishuAccount = { config: FeishuConfigSchema.parse({}), }; -function isRecord(value: unknown): value is Record { +function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -158,7 +158,7 @@ type HttpInstanceLike = { function requireHttpInstance(value: unknown): HttpInstanceLike { if ( - isRecord(value) && + isObjectRecord(value) && typeof value.request === "function" && typeof value.get === "function" && typeof value.post === "function" @@ -173,7 +173,7 @@ function readCallOptions( index = -1, ): Record { const call = index < 0 ? mock.mock.calls.at(index)?.[0] : mock.mock.calls[index]?.[0]; - return isRecord(call) ? call : {}; + return isObjectRecord(call) ? call : {}; } function firstWsClientOptions(): { diff --git a/extensions/feishu/src/comment-shared.ts b/extensions/feishu/src/comment-shared.ts index a535836e34c8..ca5de3eaae5d 100644 --- a/extensions/feishu/src/comment-shared.ts +++ b/extensions/feishu/src/comment-shared.ts @@ -1,10 +1,10 @@ // Feishu plugin module implements comment shared behavior. import { retryAsync } from "openclaw/plugin-sdk/retry-runtime"; import { - isRecord as sharedIsRecord, - normalizeOptionalString, + isRecord, + normalizeOptionalString as normalizeString, normalizeStringEntries, - readStringValue, + readStringValue as readString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { FEISHU_COMMENT_FILE_TYPES, type CommentFileType } from "./comment-target.js"; import { @@ -24,12 +24,6 @@ export function encodeQuery(params: Record): string return queryString ? `?${queryString}` : ""; } -export const readString = readStringValue; - -export const normalizeString = normalizeOptionalString; - -export const isRecord = sharedIsRecord; - export function formatFeishuApiError( error: unknown, options: { diff --git a/extensions/feishu/src/drive.ts b/extensions/feishu/src/drive.ts index a6047fc38e59..6cab3cf67fc0 100644 --- a/extensions/feishu/src/drive.ts +++ b/extensions/feishu/src/drive.ts @@ -2,16 +2,11 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; +import { isRecord, readStringValue as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js"; -import { - encodeQuery, - extractReplyText, - formatFeishuApiError, - isRecord, - readString, -} from "./comment-shared.js"; +import { encodeQuery, extractReplyText, formatFeishuApiError } from "./comment-shared.js"; import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js"; import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js"; import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js"; diff --git a/extensions/feishu/src/feishu-ingress.ts b/extensions/feishu/src/feishu-ingress.ts index f3754f1c5797..7bb49c3bbb13 100644 --- a/extensions/feishu/src/feishu-ingress.ts +++ b/extensions/feishu/src/feishu-ingress.ts @@ -12,6 +12,7 @@ import { import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import { collectErrorGraphCandidates, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { ChannelReplayClaimHandle } from "openclaw/plugin-sdk/persistent-dedupe"; +import { normalizeNullableString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getFeishuRuntime } from "./runtime.js"; const FEISHU_INGRESS_PAYLOAD_VERSION = 1; @@ -75,10 +76,6 @@ export const FeishuIngressPermanentError = createChannelIngressError< >("FeishuIngressPermanentError", { withReason: true }); export type FeishuIngressPermanentError = InstanceType; -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim() ? value.trim() : null; -} - function parseRawEnvelope(rawEnvelope: string): Record { let parsed: unknown; try { @@ -103,7 +100,7 @@ function decryptEnvelope( envelope: Record, encryptKey: string | undefined, ): Record { - const encrypted = readString(envelope.encrypt); + const encrypted = normalizeNullableString(envelope.encrypt); if (!encrypted) { return envelope; } @@ -136,13 +133,16 @@ function inspectFeishuIngressEnvelope( const envelope = decryptEnvelope(parseRawEnvelope(rawEnvelope), encryptKey); const nestedHeader = isRecord(envelope.header) ? envelope.header : null; const nestedEvent = isRecord(envelope.event) ? envelope.event : null; - const eventType = readString(nestedHeader?.event_type) ?? readString(envelope.event_type); + const eventType = + normalizeNullableString(nestedHeader?.event_type) ?? + normalizeNullableString(envelope.event_type); if (!eventType || !FEISHU_DURABLE_EVENT_TYPES.has(eventType)) { return null; } // Lark v2 carries delivery identity in header.event_id. The flattened shape // is also accepted because EventDispatcher hands that exact shape to handlers. - const eventId = readString(nestedHeader?.event_id) ?? readString(envelope.event_id); + const eventId = + normalizeNullableString(nestedHeader?.event_id) ?? normalizeNullableString(envelope.event_id); if (!eventId) { throw new FeishuIngressPermanentError( "invalid-event", @@ -152,7 +152,7 @@ function inspectFeishuIngressEnvelope( const event = nestedEvent ?? envelope; if (eventType === "im.message.receive_v1") { const message = isRecord(event.message) ? event.message : null; - const chatId = readString(message?.chat_id); + const chatId = normalizeNullableString(message?.chat_id); if (!chatId) { if (allowInvalidLane) { return { eventId, eventType, laneKey: `invalid:${eventType}:${eventId}` }; @@ -165,8 +165,8 @@ function inspectFeishuIngressEnvelope( return { eventId, eventType, laneKey: `chat:${chatId}` }; } const noticeMeta = isRecord(event.notice_meta) ? event.notice_meta : null; - const fileType = readString(noticeMeta?.file_type); - const documentId = readString(noticeMeta?.file_token); + const fileType = normalizeNullableString(noticeMeta?.file_type); + const documentId = normalizeNullableString(noticeMeta?.file_token); if (!fileType || !documentId) { if (allowInvalidLane) { return { eventId, eventType, laneKey: `invalid:${eventType}:${eventId}` }; @@ -483,7 +483,7 @@ export function createFeishuDurableIngress(options: FeishuIngressOptions): Feish invoke, invokeWebhook, resolveLifecycle: (data) => { - const eventId = isRecord(data) ? readString(data.event_id) : null; + const eventId = isRecord(data) ? normalizeNullableString(data.event_id) : null; return eventId ? activeLifecycles.get(eventId) : undefined; }, setSocketTerminator: (terminate) => { diff --git a/extensions/feishu/src/monitor.account.ts b/extensions/feishu/src/monitor.account.ts index d1ce35994bd6..62cca798d3ef 100644 --- a/extensions/feishu/src/monitor.account.ts +++ b/extensions/feishu/src/monitor.account.ts @@ -1,6 +1,7 @@ // Feishu plugin module implements monitor.account behavior. import * as crypto from "node:crypto"; import type * as Lark from "@larksuiteoapi/node-sdk"; +import { isRecord, readStringValue as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv, HistoryEntry } from "../runtime-api.js"; import { raceWithTimeoutAndAbort } from "./async.js"; import { @@ -11,7 +12,6 @@ import { } from "./bot.js"; import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js"; import { createEventDispatcher } from "./client.js"; -import { isRecord, readString } from "./comment-shared.js"; import { hasProcessedFeishuMessage, warmupDedupFromPluginState } from "./dedup.js"; import { createFeishuDurableIngress, type FeishuIngressLifecycle } from "./feishu-ingress.js"; import { applyBotIdentityState, startBotIdentityRecovery } from "./monitor.bot-identity.js"; diff --git a/extensions/feishu/src/monitor.comment.ts b/extensions/feishu/src/monitor.comment.ts index a020dec20cdf..f0b33dc6af7a 100644 --- a/extensions/feishu/src/monitor.comment.ts +++ b/extensions/feishu/src/monitor.comment.ts @@ -1,6 +1,11 @@ // Feishu plugin module implements monitor.comment behavior. import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { asBoolean as readBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean as readBoolean, + isRecord, + normalizeOptionalString as normalizeString, + readStringValue as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { ClawdbotConfig } from "../runtime-api.js"; import { raceWithTimeoutAndAbort, waitForAbortableDelay } from "./async.js"; @@ -8,12 +13,9 @@ import { createFeishuClient } from "./client.js"; import { encodeQuery, extractReplyText, - isRecord, - normalizeString, parseCommentContentElements, type ParsedCommentContent, type ParsedCommentLinkedDocument, - readString, } from "./comment-shared.js"; import { normalizeCommentFileType, type CommentFileType } from "./comment-target.js"; import type { ResolvedFeishuAccount } from "./types.js"; diff --git a/extensions/feishu/src/post.ts b/extensions/feishu/src/post.ts index 0d879e65788a..7ddbfb713d86 100644 --- a/extensions/feishu/src/post.ts +++ b/extensions/feishu/src/post.ts @@ -1,6 +1,8 @@ // Feishu plugin module implements post behavior. -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { isRecord } from "./comment-shared.js"; +import { + isRecord, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeFeishuExternalKey } from "./external-keys.js"; const FALLBACK_POST_TEXT = "[Rich text message]"; diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index b1a7a2b4d1aa..ce5577402379 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -5,6 +5,7 @@ import { createChannelPartialDeliveryError, isChannelPartialDeliveryError, } from "openclaw/plugin-sdk/channel-inbound"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; type StreamingSessionStub = { @@ -322,10 +323,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { return { ...result.dispatcherOptions, ...result.delivery }; } - function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); - } - function requireRecord(value: unknown, label: string): Record { expect(isRecord(value), `${label} must be an object`).toBe(true); return value as Record; diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 16aa660b026a..9ef31a9c11cc 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -77,7 +77,7 @@ function mergeStreamingFinalText( return `${previousText}\n\n${nextText}`; } -function toError(error: unknown): Error { +function toFeishuError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } @@ -851,7 +851,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (isChannelPartialDeliveryError(error)) { // The attachment is already visible; text recovery would duplicate delivery. markVisibleReplySent(); - throw toError(error); + throw toFeishuError(error); } const fallbackText = await buildFeishuMediaFallbackText({ text: sentFallbackText ? undefined : options.fallbackText, @@ -1069,7 +1069,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } } if (closeOutcome.error !== undefined) { - throw toError(closeOutcome.error); + throw toFeishuError(closeOutcome.error); } } while (pendingStreamingDeliveries.length > 0); } finally { @@ -1313,7 +1313,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP : undefined; if (!shouldDeliverText && !hasMedia) { if (priorClosedStreamingSettlement?.error !== undefined) { - throw toError(priorClosedStreamingSettlement.error); + throw toFeishuError(priorClosedStreamingSettlement.error); } return priorClosedStreamingSettlement?.result ?? noVisibleFeishuReplyDelivery; } diff --git a/extensions/file-transfer/src/shared/node-invoke-policy.ts b/extensions/file-transfer/src/shared/node-invoke-policy.ts index e032bfe6247b..11bb4f7b726d 100644 --- a/extensions/file-transfer/src/shared/node-invoke-policy.ts +++ b/extensions/file-transfer/src/shared/node-invoke-policy.ts @@ -9,6 +9,7 @@ import type { OpenClawPluginNodeInvokePolicyResult, } from "openclaw/plugin-sdk/plugin-entry"; import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { projectBoundedTextTail } from "./append-bounded-text-tail.js"; import { appendFileTransferAudit, type FileTransferAuditOp } from "./audit.js"; import { @@ -29,12 +30,6 @@ const DIR_FETCH_ARCHIVE_LIST_ERROR_STDERR_CHARS = 200; type FileTransferCommand = FileTransferNodeInvokeCommand; -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function readPath(params: Record): string { return typeof params.path === "string" ? params.path.trim() : ""; } @@ -750,7 +745,7 @@ async function handleFileTransferInvoke( } const command = ctx.command as FileTransferCommand; const op: FileTransferAuditOp = command; - const params = asRecord(ctx.params); + const params = asOptionalRecord(ctx.params) ?? {}; const requestedPath = readPath(params); const nodeDisplayName = ctx.node?.displayName; const startedAt = Date.now(); diff --git a/extensions/file-transfer/src/shared/params.ts b/extensions/file-transfer/src/shared/params.ts index db47f4f8a5c7..5251eaf7fc4b 100644 --- a/extensions/file-transfer/src/shared/params.ts +++ b/extensions/file-transfer/src/shared/params.ts @@ -27,18 +27,6 @@ export function readTrimmedString(params: Record, key: string): return typeof value === "string" ? value.trim() : ""; } -export function readBoolean( - params: Record, - key: string, - defaultValue = false, -): boolean { - const value = params[key]; - if (typeof value === "boolean") { - return value; - } - return defaultValue; -} - export function readClampedInt(params: { input: Record; key: string; diff --git a/extensions/file-transfer/src/tools/dir-fetch-tool.ts b/extensions/file-transfer/src/tools/dir-fetch-tool.ts index 9c196a41d0d5..f515a9655ae8 100644 --- a/extensions/file-transfer/src/tools/dir-fetch-tool.ts +++ b/extensions/file-transfer/src/tools/dir-fetch-tool.ts @@ -10,9 +10,10 @@ import { type ArchiveEntryKind, } from "openclaw/plugin-sdk/archive"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store"; +import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; import { appendFileTransferAudit } from "../shared/audit.js"; import { IMAGE_MIME_INLINE_SET, mimeFromExtension } from "../shared/mime.js"; -import { humanSize, readBoolean, readClampedInt } from "../shared/params.js"; +import { humanSize, readClampedInt } from "../shared/params.js"; import { DIR_FETCH_DEFAULT_MAX_BYTES, DIR_FETCH_HARD_MAX_BYTES, @@ -134,7 +135,7 @@ export function createDirFetchTool(): AnyAgentTool { hardMin: 1, hardMax: DIR_FETCH_HARD_MAX_BYTES, }); - const includeDotfiles = readBoolean(params, "includeDotfiles", false); + const includeDotfiles = asBoolean(params.includeDotfiles) ?? false; const { nodeId, nodeDisplayName, payload, startedAt } = await invokeNodeToolPayload({ node, diff --git a/extensions/file-transfer/src/tools/file-write-tool.ts b/extensions/file-transfer/src/tools/file-write-tool.ts index 7e7333459071..fda64062e848 100644 --- a/extensions/file-transfer/src/tools/file-write-tool.ts +++ b/extensions/file-transfer/src/tools/file-write-tool.ts @@ -2,9 +2,10 @@ import crypto from "node:crypto"; import type { AnyAgentTool } from "openclaw/plugin-sdk/agent-harness-runtime"; import { readMediaBuffer } from "openclaw/plugin-sdk/media-store"; +import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; import { appendFileTransferAudit } from "../shared/audit.js"; import { inspectStrictBase64 } from "../shared/base64.js"; -import { humanSize, readBoolean } from "../shared/params.js"; +import { humanSize } from "../shared/params.js"; import { FILE_TRANSFER_SUBDIR, FILE_WRITE_HARD_MAX_BYTES, @@ -73,8 +74,8 @@ export function createFileWriteTool(): AnyAgentTool { const { node: nodeQuery, requestedPath: filePath } = readRequiredNodePath(raw); const contentBase64 = typeof raw.contentBase64 === "string" ? raw.contentBase64 : undefined; const sourceMediaId = typeof raw.sourceMediaId === "string" ? raw.sourceMediaId : undefined; - const overwrite = readBoolean(raw, "overwrite", false); - const createParents = readBoolean(raw, "createParents", false); + const overwrite = asBoolean(raw.overwrite) ?? false; + const createParents = asBoolean(raw.createParents) ?? false; // Compute the sha256 of the bytes we're sending so the node can do // an end-to-end integrity check after writing. This is always diff --git a/extensions/fish-audio-speech/speech-provider.ts b/extensions/fish-audio-speech/speech-provider.ts index 59d6d36b8e33..901382e0066e 100644 --- a/extensions/fish-audio-speech/speech-provider.ts +++ b/extensions/fish-audio-speech/speech-provider.ts @@ -12,11 +12,11 @@ import type { import { asBoolean, asFiniteNumber, - asObject, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { FISH_AUDIO_STREAM_MAX_BYTES, type FishAudioFormat, @@ -80,8 +80,9 @@ function resolveReferenceId(raw: Record | undefined): string | } function normalizeProviderConfig(rawConfig: Record): FishAudioProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.["fish-audio"]) ?? asObject(rawConfig["fish-audio"]); + const providers = asOptionalRecord(rawConfig.providers); + const raw = + asOptionalRecord(providers?.["fish-audio"]) ?? asOptionalRecord(rawConfig["fish-audio"]); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, @@ -100,7 +101,7 @@ function normalizeProviderConfig(rawConfig: Record): FishAudioP function readProviderConfig(config: SpeechProviderConfig): FishAudioProviderConfig { const defaults = normalizeProviderConfig({}); - const raw = asObject(config) ?? {}; + const raw = asOptionalRecord(config) ?? {}; return { apiKey: trimToUndefined(raw.apiKey) ?? defaults.apiKey, baseUrl: normalizeFishAudioBaseUrl(trimToUndefined(raw.baseUrl) ?? defaults.baseUrl), @@ -115,7 +116,7 @@ function readProviderConfig(config: SpeechProviderConfig): FishAudioProviderConf } function readOverrides(overrides: SpeechProviderOverrides | undefined): FishAudioOverrides { - const raw = asObject(overrides) ?? {}; + const raw = asOptionalRecord(overrides) ?? {}; return { model: trimToUndefined(raw.model ?? raw.modelId) ? normalizeModel(raw.model ?? raw.modelId) diff --git a/extensions/fish-audio-speech/tts.ts b/extensions/fish-audio-speech/tts.ts index f1b6d6892458..f705fb66116c 100644 --- a/extensions/fish-audio-speech/tts.ts +++ b/extensions/fish-audio-speech/tts.ts @@ -6,11 +6,12 @@ import { readProviderBinaryResponse, readProviderJsonResponse, } from "openclaw/plugin-sdk/provider-http"; -import { asObject, trimToUndefined, type SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; +import { trimToUndefined, type SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const FISH_AUDIO_BASE_URL = "https://api.fish.audio"; const FISH_AUDIO_VOICES_MAX_BYTES = 2 * 1024 * 1024; @@ -205,7 +206,7 @@ type FishAudioVoicePayload = { }; function parseVoiceItem(value: unknown): SpeechVoiceOption | undefined { - const item = asObject(value); + const item = asOptionalRecord(value); const id = trimToUndefined(item?.["_id"]); if (!id) { return undefined; diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index e4682eb45857..f94da72d5181 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -21,6 +21,7 @@ import type { RealtimeVoiceBridge, RealtimeVoiceProviderPlugin, } from "openclaw/plugin-sdk/realtime-voice"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; // Google Meet tests cover index plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -573,10 +574,6 @@ function requireConfigProperty( return value as Record; } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - type MockSessionEntry = { sessionId?: string; updatedAt?: number; diff --git a/extensions/google-meet/src/config-compat.ts b/extensions/google-meet/src/config-compat.ts index f970ef3b4ae5..6c5fbb0c4240 100644 --- a/extensions/google-meet/src/config-compat.ts +++ b/extensions/google-meet/src/config-compat.ts @@ -1,7 +1,7 @@ // Google Meet helper module supports config compat behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { - asNullableRecord as asRecord, + asNullableRecord, normalizeOptionalLowercaseString as normalizeProviderId, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -16,7 +16,7 @@ function hasOwn(record: Record, key: string): boolean { } function hasLegacyGoogleRealtimeProvider(value: unknown): boolean { - const realtime = asRecord(value); + const realtime = asNullableRecord(value); if (!realtime || normalizeProviderId(realtime.provider) !== "google") { return false; } @@ -36,23 +36,23 @@ function migrateGoogleMeetLegacyRealtimeProvider(config: OpenClawConfig): { config: OpenClawConfig; changes: string[]; } | null { - const rawEntry = asRecord(config.plugins?.entries?.["google-meet"]); - const rawPluginConfig = asRecord(rawEntry?.config); - const rawRealtime = asRecord(rawPluginConfig?.realtime); + const rawEntry = asNullableRecord(config.plugins?.entries?.["google-meet"]); + const rawPluginConfig = asNullableRecord(rawEntry?.config); + const rawRealtime = asNullableRecord(rawPluginConfig?.realtime); if (!rawRealtime || !hasLegacyGoogleRealtimeProvider(rawRealtime)) { return null; } const nextConfig = structuredClone(config); - const nextPlugins = asRecord(nextConfig.plugins) ?? {}; + const nextPlugins = asNullableRecord(nextConfig.plugins) ?? {}; nextConfig.plugins = nextPlugins; - const nextEntries = asRecord(nextPlugins.entries) ?? {}; + const nextEntries = asNullableRecord(nextPlugins.entries) ?? {}; nextPlugins.entries = nextEntries; - const nextEntry = asRecord(nextEntries["google-meet"]) ?? {}; + const nextEntry = asNullableRecord(nextEntries["google-meet"]) ?? {}; nextEntries["google-meet"] = nextEntry; - const nextPluginConfig = asRecord(nextEntry.config) ?? {}; + const nextPluginConfig = asNullableRecord(nextEntry.config) ?? {}; nextEntry.config = nextPluginConfig; - const nextRealtime = asRecord(nextPluginConfig.realtime) ?? {}; + const nextRealtime = asNullableRecord(nextPluginConfig.realtime) ?? {}; nextPluginConfig.realtime = nextRealtime; nextRealtime.provider = "openai"; diff --git a/extensions/google/cli-backend-auth.runtime.ts b/extensions/google/cli-backend-auth.runtime.ts index 625cb4bef3b8..922b1cdd0787 100644 --- a/extensions/google/cli-backend-auth.runtime.ts +++ b/extensions/google/cli-backend-auth.runtime.ts @@ -6,7 +6,7 @@ import { type CliBackendPreparedExecution, type CliBackendToolAvailability, } from "openclaw/plugin-sdk/cli-backend"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { assertGeminiCliLiteralIsolatedPrompt, @@ -95,11 +95,6 @@ type GeminiCliPreparedExecution = CliBackendPreparedExecution & { isolatedCompletionEnforced?: true; }; -function normalizeString(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - function throwUnsupportedGeminiCredential(credential: GeminiAuthProfileCredential): never { // Route compatibility is not credential-health evidence. Keep this local so // a profile that is valid for its owner is not quarantined across providers. @@ -115,7 +110,7 @@ function throwUnstageableSelectedGeminiProfile( ctx: GeminiCliAuthHomeContext, credential: GeminiAuthProfileCredential | undefined, ): never { - const authProfileId = normalizeString(ctx.authProfileId); + const authProfileId = normalizeOptionalString(ctx.authProfileId); if (!authProfileId) { throw new Error("Gemini CLI execution requires a selected auth profile."); } @@ -147,8 +142,8 @@ function requireGeminiOAuthCredential( throwUnsupportedGeminiCredential(credential); } - const access = normalizeString(credential.access); - const refresh = normalizeString(credential.refresh); + const access = normalizeOptionalString(credential.access); + const refresh = normalizeOptionalString(credential.refresh); if ( !access || !refresh || @@ -167,8 +162,8 @@ function requireGeminiOAuthCredential( access, refresh, expires: credential.expires, - idToken: normalizeString(credential.idToken), - projectId: normalizeString(credential.projectId), + idToken: normalizeOptionalString(credential.idToken), + projectId: normalizeOptionalString(credential.projectId), }; } @@ -188,7 +183,7 @@ function requireGeminiApiKeyCredential( throwUnsupportedGeminiCredential(credential); } - const key = normalizeString(credential.key); + const key = normalizeOptionalString(credential.key); if (!key) { throw new CliBackendAuthProfilePreparationError( "Gemini CLI API-key profile is missing usable key material.", @@ -207,11 +202,11 @@ function resolveGeminiCliProfileHome(ctx: GeminiCliAuthHomeContext): { home: string; geminiDir: string; } { - const agentDir = normalizeString(ctx.agentDir); + const agentDir = normalizeOptionalString(ctx.agentDir); if (!agentDir) { throw new Error("Gemini CLI auth profile execution requires an agent directory."); } - const authProfileId = normalizeString(ctx.authProfileId); + const authProfileId = normalizeOptionalString(ctx.authProfileId); if (!authProfileId) { throw new Error("Gemini CLI auth profile execution requires a selected auth profile."); } @@ -260,7 +255,7 @@ async function buildGeminiCliSystemSettings( if (selectedType) { const security = isRecord(settings.security) ? { ...settings.security } : {}; const auth = isRecord(security.auth) ? { ...security.auth } : {}; - const enforcedType = normalizeString( + const enforcedType = normalizeOptionalString( typeof auth.enforcedType === "string" ? auth.enforcedType : undefined, ); if (enforcedType && enforcedType !== selectedType) { @@ -284,7 +279,7 @@ function applyGeminiCliIsolatedCompletionSettings( if (ctx.isolatedCompletionSystemPrompt === undefined) { return base; } - const modelId = normalizeString(ctx.isolatedCompletionModelId); + const modelId = normalizeOptionalString(ctx.isolatedCompletionModelId); if (!modelId || modelId === "auto" || modelId.startsWith("auto-")) { throw isolatedCompletionInputError( "Gemini isolated completion requires one concrete model id.", @@ -463,7 +458,7 @@ async function writeGeminiCliPrivateFile(filePath: string, value: string): Promi } async function stageGeminiCliIsolatedCwd(ctx: GeminiCliAuthHomeContext): Promise { - const cwd = normalizeString(ctx.isolatedCompletionCwd); + const cwd = normalizeOptionalString(ctx.isolatedCompletionCwd); if (!cwd) { return; } @@ -531,7 +526,7 @@ async function clearGeminiCliCachedCredentials(geminiDir: string): Promise } function buildGeminiCliProjectEnv(projectId: string | undefined): Record { - const normalized = normalizeString(projectId); + const normalized = normalizeOptionalString(projectId); if (!normalized) { return {}; } @@ -559,7 +554,7 @@ async function prepareGeminiCliOAuthHome( } const profileHome = await prepareGeminiCliProfileHome(ctx, "oauth-personal"); - const idToken = normalizeString(oauth.idToken); + const idToken = normalizeOptionalString(oauth.idToken); const oauthCreds: Record = { access_token: oauth.access, refresh_token: oauth.refresh, @@ -710,7 +705,7 @@ export async function prepareGeminiCliExecution( if (prepared) { return ctx.toolAvailability ? { ...prepared, toolAvailabilityEnforced: true } : prepared; } - if (normalizeString(ctx.authProfileId)) { + if (normalizeOptionalString(ctx.authProfileId)) { throwUnstageableSelectedGeminiProfile(ctx, authCredential); } return ctx.toolAvailability ? await prepareGeminiCliRestrictedSystemSettings(ctx) : null; diff --git a/extensions/google/cli-backend-isolated-auth.runtime.ts b/extensions/google/cli-backend-isolated-auth.runtime.ts index fda4bd21a620..d0082318370d 100644 --- a/extensions/google/cli-backend-isolated-auth.runtime.ts +++ b/extensions/google/cli-backend-isolated-auth.runtime.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { parse as parseDotEnv } from "dotenv"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const GEMINI_CLI_AMBIENT_AUTH_ENV = new Set([ "GEMINI_API_KEY", @@ -65,11 +65,6 @@ type GeminiCliAmbientEnv = { telemetryEnabled?: boolean; }; -function normalizeString(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - // Gemini CLI 0.39.1 runs this commandUtils grammar before pasted-text unescape; // any immediate backslash suppresses inclusion. Parity logic would reject // prompts the pinned CLI keeps literal. @@ -127,7 +122,7 @@ export function assertGeminiCliLiteralIsolatedPrompt(ctx: GeminiCliRestrictedAut export async function readGeminiCliJsonObject( filePath: string | undefined, ): Promise> { - const normalized = normalizeString(filePath); + const normalized = normalizeOptionalString(filePath); if (!normalized) { return {}; } @@ -172,8 +167,8 @@ function projectGeminiCliSafeSettings(settings: Record): Record function resolveGeminiCliAmbientHome(ctx: GeminiCliRestrictedAuthContext): string { return ( - normalizeString(ctx.baseEnv?.GEMINI_CLI_HOME) ?? - normalizeString(process.env.GEMINI_CLI_HOME) ?? + normalizeOptionalString(ctx.baseEnv?.GEMINI_CLI_HOME) ?? + normalizeOptionalString(process.env.GEMINI_CLI_HOME) ?? os.homedir() ); } @@ -185,9 +180,9 @@ function projectGeminiCliTrustedTransportEnv( return Object.fromEntries( [...GEMINI_CLI_TRUSTED_TRANSPORT_ENV].map((name) => [ name, - normalizeString(ctx.baseEnv?.[name]) ?? - normalizeString(process.env[name]) ?? - normalizeString(ambientEnv.transport[name]) ?? + normalizeOptionalString(ctx.baseEnv?.[name]) ?? + normalizeOptionalString(process.env[name]) ?? + normalizeOptionalString(ambientEnv.transport[name]) ?? "", ]), ); @@ -260,20 +255,20 @@ export async function resolveGeminiCliAmbientAuth( systemSecurity && isRecord(systemSecurity.auth) ? systemSecurity.auth : undefined; const ambientEnv = await loadGeminiCliAmbientEnv(ctx); const preparedSelectorOwnsAuth = [...GEMINI_CLI_AUTH_SELECTOR_ENV].some((name) => { - const value = normalizeString(ctx.baseEnv?.[name]); + const value = normalizeOptionalString(ctx.baseEnv?.[name]); return value !== undefined && value !== "false" && value !== "0"; }); - const systemSelectedType = normalizeString( + const systemSelectedType = normalizeOptionalString( typeof systemAuth?.selectedType === "string" ? systemAuth.selectedType : undefined, ); - const userSelectedType = normalizeString( + const userSelectedType = normalizeOptionalString( typeof userAuth?.selectedType === "string" ? userAuth.selectedType : undefined, ); // A request-prepared selector is the credential owner for this turn. It may // override ambient user preference, but never system-enforced selection. const selectedType = systemSelectedType ?? (preparedSelectorOwnsAuth ? undefined : userSelectedType); - const enforcedType = normalizeString( + const enforcedType = normalizeOptionalString( typeof systemAuth?.enforcedType === "string" ? systemAuth.enforcedType : undefined, ); if (enforcedType && enforcedType !== "gemini-api-key" && enforcedType !== "vertex-ai") { @@ -283,7 +278,7 @@ export async function resolveGeminiCliAmbientAuth( ); } const envValue = (name: string): string | undefined => { - const prepared = normalizeString(ctx.baseEnv?.[name]); + const prepared = normalizeOptionalString(ctx.baseEnv?.[name]); if (prepared !== undefined) { return prepared; } @@ -291,9 +286,9 @@ export async function resolveGeminiCliAmbientAuth( return undefined; } return ( - normalizeString(process.env[name]) ?? - normalizeString(ambientEnv.auth[name]) ?? - normalizeString(ambientEnv.unsafeAuth[name]) + normalizeOptionalString(process.env[name]) ?? + normalizeOptionalString(ambientEnv.auth[name]) ?? + normalizeOptionalString(ambientEnv.unsafeAuth[name]) ); }; // Gemini CLI selects auth from selectedType or its auth env, then compares @@ -333,9 +328,11 @@ export async function resolveGeminiCliAmbientAuth( envOverrides[name] = value; } } - const applicationCredentials = normalizeString(envOverrides.GOOGLE_APPLICATION_CREDENTIALS); + const applicationCredentials = normalizeOptionalString( + envOverrides.GOOGLE_APPLICATION_CREDENTIALS, + ); if (applicationCredentials && !path.isAbsolute(applicationCredentials)) { - const workspaceDir = normalizeString(ctx.workspaceDir); + const workspaceDir = normalizeOptionalString(ctx.workspaceDir); if (!workspaceDir) { throw new Error( "Gemini exact tool availability cannot resolve relative GOOGLE_APPLICATION_CREDENTIALS without a workspace.", diff --git a/extensions/google/embedding-provider.ts b/extensions/google/embedding-provider.ts index f8d54dd2fc02..115323fcb791 100644 --- a/extensions/google/embedding-provider.ts +++ b/extensions/google/embedding-provider.ts @@ -22,7 +22,7 @@ import { } from "openclaw/plugin-sdk/provider-http"; import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { parseGeminiAuth } from "./gemini-auth.js"; @@ -88,7 +88,7 @@ function readGeminiEmbeddingValues(value: unknown): number[] { } function readGeminiSingleEmbedding(payload: Record): number[] { - const embedding = asRecord(payload.embedding); + const embedding = asOptionalRecord(payload.embedding); if (!embedding) { throw malformedGeminiEmbeddingResponse(); } @@ -103,7 +103,7 @@ function readGeminiBatchEmbeddings( throw malformedGeminiEmbeddingResponse(); } return payload.embeddings.map((entry) => { - const embedding = asRecord(entry); + const embedding = asOptionalRecord(entry); if (!embedding) { throw malformedGeminiEmbeddingResponse(); } diff --git a/extensions/google/provider-catalog.ts b/extensions/google/provider-catalog.ts index 90b41fcdb3d7..6ae43afeec40 100644 --- a/extensions/google/provider-catalog.ts +++ b/extensions/google/provider-catalog.ts @@ -7,6 +7,10 @@ import type { ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; +import { + asPositiveSafeInteger, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { isGoogleTextGenerationModelId, resolveGoogleStaticModelId } from "./provider-models.js"; const GOOGLE_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; @@ -67,16 +71,6 @@ function readGoogleLiveModels(body: unknown): readonly unknown[] { return Array.isArray(models) ? models : []; } -function readString(row: Record, key: string): string | undefined { - const value = row[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function readPositiveInteger(row: Record, key: string): number | undefined { - const value = row[key]; - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; -} - function googleLiveModelInput(id: string): ModelDefinitionConfig["input"] { if (!id.startsWith("gemma-")) { return ["text", "image"]; @@ -93,11 +87,11 @@ function buildGoogleLiveModel(row: unknown): ModelDefinitionConfig | undefined { return undefined; } const record = row as Record; - const resourceName = readString(record, "name"); + const resourceName = normalizeOptionalString(record.name); const id = resourceName?.startsWith("models/") ? resourceName.slice("models/".length) : undefined; const methods = record.supportedGenerationMethods; - const contextWindow = readPositiveInteger(record, "inputTokenLimit"); - const maxTokens = readPositiveInteger(record, "outputTokenLimit"); + const contextWindow = asPositiveSafeInteger(record.inputTokenLimit); + const maxTokens = asPositiveSafeInteger(record.outputTokenLimit); if ( !id || !isGoogleTextGenerationModelId(id) || @@ -115,7 +109,7 @@ function buildGoogleLiveModel(row: unknown): ModelDefinitionConfig | undefined { const staticModel = staticId ? GOOGLE_GEMINI_TEXT_MODEL_BY_ID.get(staticId) : undefined; return { id, - name: readString(record, "displayName") ?? id, + name: normalizeOptionalString(record.displayName) ?? id, reasoning: record.thinking === true, // models.list omits modalities. Gemma has both text-only small variants and // multimodal families, so keep this capability distinction explicit. diff --git a/extensions/google/speech-provider.ts b/extensions/google/speech-provider.ts index d53e04d50e7c..482444945fb6 100644 --- a/extensions/google/speech-provider.ts +++ b/extensions/google/speech-provider.ts @@ -15,8 +15,11 @@ import type { SpeechProviderPlugin, SpeechSynthesisRequest, } from "openclaw/plugin-sdk/speech-core"; -import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { trimToUndefined } from "openclaw/plugin-sdk/speech-core"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveGoogleGenerativeAiHttpRequestConfig } from "./api.js"; import { canonicalizeGoogleProviderBase64 } from "./base64.js"; @@ -193,8 +196,8 @@ function resolveGoogleTtsBaseUrl(params: { function resolveGoogleTtsConfigRecord( rawConfig: Record, ): Record | undefined { - const providers = asObject(rawConfig.providers); - return asObject(providers?.google) ?? asObject(rawConfig.google); + const providers = asOptionalRecord(rawConfig.providers); + return asOptionalRecord(providers?.google) ?? asOptionalRecord(rawConfig.google); } function normalizeGoogleTtsProviderConfig( diff --git a/extensions/gradium/speech-provider.ts b/extensions/gradium/speech-provider.ts index 381edc65bbac..76aa3af52709 100644 --- a/extensions/gradium/speech-provider.ts +++ b/extensions/gradium/speech-provider.ts @@ -6,8 +6,9 @@ import type { SpeechProviderConfig, SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech"; -import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech"; +import { trimToUndefined } from "openclaw/plugin-sdk/speech"; import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_GRADIUM_VOICE_ID, GRADIUM_VOICES, normalizeGradiumBaseUrl } from "./shared.js"; import { gradiumTTS } from "./tts.js"; @@ -18,8 +19,8 @@ type GradiumProviderConfig = { }; function normalizeGradiumProviderConfig(rawConfig: Record): GradiumProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.gradium) ?? asObject(rawConfig.gradium); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.gradium) ?? asOptionalRecord(rawConfig.gradium); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, diff --git a/extensions/inworld/speech-provider.ts b/extensions/inworld/speech-provider.ts index 0ed8a756a653..9886aa962d04 100644 --- a/extensions/inworld/speech-provider.ts +++ b/extensions/inworld/speech-provider.ts @@ -7,12 +7,11 @@ import type { SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; import { - asObject, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; -import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_INWORLD_MODEL_ID, DEFAULT_INWORLD_VOICE_ID, @@ -42,8 +41,8 @@ function normalizeInworldTemperature(value: unknown): number | undefined { } function normalizeInworldProviderConfig(rawConfig: Record): InworldProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.inworld) ?? asObject(rawConfig.inworld); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.inworld) ?? asOptionalRecord(rawConfig.inworld); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, diff --git a/extensions/irc/src/client.ts b/extensions/irc/src/client.ts index 871ca03000fa..7104d502ac22 100644 --- a/extensions/irc/src/client.ts +++ b/extensions/irc/src/client.ts @@ -92,7 +92,7 @@ export type IrcClient = { close: () => void; }; -function toError(err: unknown): Error { +function toIrcError(err: unknown): Error { if (err instanceof Error) { return err; } @@ -175,7 +175,7 @@ export async function connectIrcClient(options: IrcClientOptions): Promise { - const error = toError(err); + const error = toIrcError(err); if (options.onError) { options.onError(error); } diff --git a/extensions/logbook/src/config.ts b/extensions/logbook/src/config.ts index a98a28aefac1..886b798dcd4f 100644 --- a/extensions/logbook/src/config.ts +++ b/extensions/logbook/src/config.ts @@ -1,4 +1,5 @@ // Logbook plugin config resolution: clamps operator input into safe runtime bounds. +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; export type LogbookConfig = { captureEnabled: boolean; @@ -25,14 +26,6 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number) return Math.min(max, Math.max(min, Math.round(num))); } -function optionalString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - export function resolveLogbookConfig(raw: unknown): LogbookConfig { const value = (raw && typeof raw === "object" ? raw : {}) as Record; return { @@ -49,10 +42,10 @@ export function resolveLogbookConfig(raw: unknown): LogbookConfig { 3, 120, ), - nodeId: optionalString(value.nodeId), + nodeId: normalizeOptionalString(value.nodeId), screenIndex: clampNumber(value.screenIndex, DEFAULTS.screenIndex, 0, 16), maxWidth: clampNumber(value.maxWidth, DEFAULTS.maxWidth, 480, 3840), - visionModel: optionalString(value.visionModel), + visionModel: normalizeOptionalString(value.visionModel), retentionDays: clampNumber(value.retentionDays, DEFAULTS.retentionDays, 1, 365), }; } diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index 101615c4aa86..7724b3cc3f36 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -13,6 +13,7 @@ import { sendPayloadMediaSequence, } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; import type { MatrixExtraContentFields } from "./matrix/send/types.js"; import { @@ -29,15 +30,9 @@ type MatrixChannelData = { extraContent?: MatrixExtraContentFields; }; -function toRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function resolveMatrixChannelData(payload: ReplyPayload): MatrixChannelData { - const raw = toRecord(payload.channelData)?.matrix; - return (toRecord(raw) as MatrixChannelData | undefined) ?? {}; + const raw = asOptionalRecord(payload.channelData)?.matrix; + return (asOptionalRecord(raw) as MatrixChannelData | undefined) ?? {}; } function buildMatrixPresentationContent(presentation: MessagePresentation) { @@ -51,8 +46,8 @@ function buildMatrixPresentationContent(presentation: MessagePresentation) { function resolveMatrixPresentationContent( payload: ReplyPayload, ): Record | undefined { - const extraContent = toRecord(resolveMatrixChannelData(payload).extraContent); - const presentation = toRecord(extraContent?.[MATRIX_OPENCLAW_PRESENTATION_KEY]); + const extraContent = asOptionalRecord(resolveMatrixChannelData(payload).extraContent); + const presentation = asOptionalRecord(extraContent?.[MATRIX_OPENCLAW_PRESENTATION_KEY]); if ( !presentation || presentation.version !== 1 || diff --git a/extensions/memory-core/src/cli-runtime-common.ts b/extensions/memory-core/src/cli-runtime-common.ts index 2b3b46ac7b57..f6448475bfef 100644 --- a/extensions/memory-core/src/cli-runtime-common.ts +++ b/extensions/memory-core/src/cli-runtime-common.ts @@ -8,6 +8,7 @@ import { type MemoryExtraPath, } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { defaultRuntime, formatErrorMessage, @@ -23,7 +24,6 @@ import { type OpenClawConfig, withManager, } from "./cli.host.runtime.js"; -import { asRecord } from "./dreaming-shared.js"; import type { MemoryCoreAcquireLocalService } from "./memory/embedding-local-service.js"; import type { ShortTermAuditSummary } from "./short-term-promotion.js"; const { warn } = theme; @@ -107,8 +107,8 @@ function emitMemorySecretResolveDiagnostics( } } export function resolveMemoryPluginConfig(cfg: OpenClawConfig): Record { - const entry = asRecord(cfg.plugins?.entries?.["memory-core"]); - return asRecord(entry?.config) ?? {}; + const entry = asNullableRecord(cfg.plugins?.entries?.["memory-core"]); + return asNullableRecord(entry?.config) ?? {}; } export function formatAuditCounts(audit: ShortTermAuditSummary): string { const scriptCoverage = audit.conceptTagScripts diff --git a/extensions/memory-core/src/cli-status.runtime.ts b/extensions/memory-core/src/cli-status.runtime.ts index 6cbf101f6b58..1e8dcb70991d 100644 --- a/extensions/memory-core/src/cli-status.runtime.ts +++ b/extensions/memory-core/src/cli-status.runtime.ts @@ -3,6 +3,7 @@ import { resolveMemoryLightDreamingConfig, resolveMemoryRemDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatAuditCounts, formatExtraPaths, @@ -30,7 +31,6 @@ import { type DreamingArtifactsAuditSummary, type RepairDreamingArtifactsResult, } from "./dreaming-repair.js"; -import { asRecord } from "./dreaming-shared.js"; import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js"; import type { MemoryCoreRuntimeHost } from "./memory/runtime-host.js"; import { @@ -65,7 +65,7 @@ type LlamaCppRuntimeStatus = { function readLlamaCppRuntimeStatus( status: ReturnType, ): LlamaCppRuntimeStatus | null { - const runtime = asRecord(asRecord(status.custom)?.llamaCppRuntime); + const runtime = asNullableRecord(asNullableRecord(status.custom)?.llamaCppRuntime); return runtime?.engine === "llama.cpp" ? (runtime as LlamaCppRuntimeStatus) : null; } function formatMemoryIndexIdentityWarning( @@ -75,7 +75,7 @@ function formatMemoryIndexIdentityWarning( reason: string; fix: string; } | null { - const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity); + const indexIdentity = asNullableRecord(asNullableRecord(status.custom)?.indexIdentity); const reason = (indexIdentity?.status === "mismatched" || indexIdentity?.status === "missing") && typeof indexIdentity.reason === "string" diff --git a/extensions/memory-core/src/dreaming-command.test.ts b/extensions/memory-core/src/dreaming-command.test.ts index ac3f0cc6c864..8102e8a356bd 100644 --- a/extensions/memory-core/src/dreaming-command.test.ts +++ b/extensions/memory-core/src/dreaming-command.test.ts @@ -2,20 +2,14 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { PluginCommandContext } from "openclaw/plugin-sdk/core"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describe, expect, it, vi } from "vitest"; import { handleDreamingCommand } from "./dreaming-command.js"; -function asRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - return value as Record; -} - function resolveStoredDreaming(config: OpenClawConfig): Record { - const entry = asRecord(config.plugins?.entries?.["memory-core"]); - const pluginConfig = asRecord(entry?.config); - return asRecord(pluginConfig?.dreaming) ?? {}; + const entry = asNullableRecord(config.plugins?.entries?.["memory-core"]); + const pluginConfig = asNullableRecord(entry?.config); + return asNullableRecord(pluginConfig?.dreaming) ?? {}; } function createHarness(initialConfig: OpenClawConfig = {}) { diff --git a/extensions/memory-core/src/dreaming-command.ts b/extensions/memory-core/src/dreaming-command.ts index 83c885d7c6af..041b33191d43 100644 --- a/extensions/memory-core/src/dreaming-command.ts +++ b/extensions/memory-core/src/dreaming-command.ts @@ -2,20 +2,22 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status"; import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { asRecord } from "./dreaming-shared.js"; +import { + asNullableRecord, + normalizeLowercaseStringOrEmpty, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js"; function resolveDreamingPluginConfig(cfg: OpenClawConfig): Record { - const entry = asRecord(cfg.plugins?.entries?.["memory-core"]); - return asRecord(entry?.config) ?? {}; + const entry = asNullableRecord(cfg.plugins?.entries?.["memory-core"]); + return asNullableRecord(entry?.config) ?? {}; } function updateDreamingEnabledInConfig(cfg: OpenClawConfig, enabled: boolean): OpenClawConfig { const entries = { ...cfg.plugins?.entries }; - const existingEntry = asRecord(entries["memory-core"]) ?? {}; - const existingConfig = asRecord(existingEntry.config) ?? {}; - const existingSleep = asRecord(existingConfig.dreaming) ?? {}; + const existingEntry = asNullableRecord(entries["memory-core"]) ?? {}; + const existingConfig = asNullableRecord(existingEntry.config) ?? {}; + const existingSleep = asNullableRecord(existingConfig.dreaming) ?? {}; entries["memory-core"] = { ...existingEntry, config: { diff --git a/extensions/memory-core/src/dreaming-ingestion-state.ts b/extensions/memory-core/src/dreaming-ingestion-state.ts index 9d188c9f7e76..0d2ad9dcce12 100644 --- a/extensions/memory-core/src/dreaming-ingestion-state.ts +++ b/extensions/memory-core/src/dreaming-ingestion-state.ts @@ -1,6 +1,8 @@ // Memory Core codecs normalize canonical and legacy dreaming ingestion state. -import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { asRecord } from "./dreaming-shared.js"; +import { + asNullableRecord, + normalizeStringEntries, +} from "openclaw/plugin-sdk/string-coerce-runtime"; const MEMORY_DAY_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -40,14 +42,14 @@ export function normalizeMemoryDay(value: unknown): string | undefined { } export function normalizeDailyIngestionState(raw: unknown): DailyIngestionState { - const record = asRecord(raw); - const filesRaw = asRecord(record?.files); + const record = asNullableRecord(raw); + const filesRaw = asNullableRecord(record?.files); if (!filesRaw) { return { version: 1, files: {} }; } const files: Record = {}; for (const [key, value] of Object.entries(filesRaw)) { - const file = asRecord(value); + const file = asNullableRecord(value); if (!file || typeof key !== "string" || key.trim().length === 0) { continue; } @@ -67,12 +69,12 @@ export function normalizeDailyIngestionState(raw: unknown): DailyIngestionState } export function normalizeSessionIngestionState(raw: unknown): SessionIngestionState { - const record = asRecord(raw); - const filesRaw = asRecord(record?.files); + const record = asNullableRecord(raw); + const filesRaw = asNullableRecord(record?.files); const files: Record = {}; if (filesRaw) { for (const [key, value] of Object.entries(filesRaw)) { - const file = asRecord(value); + const file = asNullableRecord(value); if (!file || key.trim().length === 0) { continue; } @@ -98,7 +100,7 @@ export function normalizeSessionIngestionState(raw: unknown): SessionIngestionSt }; } } - const seenMessagesRaw = asRecord(record?.seenMessages); + const seenMessagesRaw = asNullableRecord(record?.seenMessages); const seenMessages: Record = {}; if (seenMessagesRaw) { for (const [scope, value] of Object.entries(seenMessagesRaw)) { diff --git a/extensions/memory-core/src/dreaming-shared.ts b/extensions/memory-core/src/dreaming-shared.ts index 0d9af9a90a2c..6aec427dd918 100644 --- a/extensions/memory-core/src/dreaming-shared.ts +++ b/extensions/memory-core/src/dreaming-shared.ts @@ -4,7 +4,6 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -export { asNullableRecord as asRecord, normalizeOptionalString as normalizeTrimmedString }; export { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; export function extractAssistantText(messages: unknown[]): string | null { diff --git a/extensions/memory-core/src/dreaming.ts b/extensions/memory-core/src/dreaming.ts index 798cbdbe8666..d24826780347 100644 --- a/extensions/memory-core/src/dreaming.ts +++ b/extensions/memory-core/src/dreaming.ts @@ -20,16 +20,13 @@ import { import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeLowercaseStringOrEmpty, + normalizeOptionalString, uniqueStrings, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { peekSystemEventEntries } from "openclaw/plugin-sdk/system-event-runtime"; import { appendFailedDreamingEvent } from "./dreaming-events.js"; import type { NarrativePhaseData } from "./dreaming-narrative.js"; -import { - formatErrorMessage, - includesSystemEventToken, - normalizeTrimmedString, -} from "./dreaming-shared.js"; +import { formatErrorMessage, includesSystemEventToken } from "./dreaming-shared.js"; const RUNTIME_CRON_RECONCILE_INTERVAL_MS = 60_000; const STARTUP_CRON_RETRY_DELAY_MS = 5_000; @@ -201,25 +198,25 @@ function buildManagedDreamingCronJob( function resolveManagedDreamingPayloadToken( payload: ManagedCronJobLike["payload"], ): string | undefined { - const payloadKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(payload?.kind)); + const payloadKind = normalizeLowercaseStringOrEmpty(normalizeOptionalString(payload?.kind)); if (payloadKind === "systemevent") { - return normalizeTrimmedString(payload?.text); + return normalizeOptionalString(payload?.text); } if (payloadKind === "agentturn") { - return normalizeTrimmedString(payload?.message); + return normalizeOptionalString(payload?.message); } return undefined; } function isManagedDreamingJob(job: ManagedCronJobLike): boolean { - if (normalizeTrimmedString(job.declarationKey) === MANAGED_DREAMING_DECLARATION_KEY) { + if (normalizeOptionalString(job.declarationKey) === MANAGED_DREAMING_DECLARATION_KEY) { return true; } - const name = normalizeTrimmedString(job.name); + const name = normalizeOptionalString(job.name); if (name !== MANAGED_DREAMING_CRON_NAME) { return false; } - const description = normalizeTrimmedString(job.description); + const description = normalizeOptionalString(job.description); if (description?.includes(MANAGED_DREAMING_CRON_TAG)) { return true; } @@ -227,15 +224,15 @@ function isManagedDreamingJob(job: ManagedCronJobLike): boolean { } function isLegacyPhaseDreamingJob(job: ManagedCronJobLike): boolean { - const description = normalizeTrimmedString(job.description); + const description = normalizeOptionalString(job.description); if ( description?.includes(LEGACY_LIGHT_SLEEP_CRON_TAG) || description?.includes(LEGACY_REM_SLEEP_CRON_TAG) ) { return true; } - const name = normalizeTrimmedString(job.name); - const payloadText = normalizeTrimmedString(job.payload?.text); + const name = normalizeOptionalString(job.name); + const payloadText = normalizeOptionalString(job.payload?.text); if (name === LEGACY_LIGHT_SLEEP_CRON_NAME && payloadText === LEGACY_LIGHT_SLEEP_EVENT_TEXT) { return true; } @@ -297,19 +294,19 @@ function buildManagedDreamingPatch( ): ManagedCronJobPatch | null { const patch: ManagedCronJobPatch = {}; - if (!compareOptionalStrings(normalizeTrimmedString(job.name), desired.name)) { + if (!compareOptionalStrings(normalizeOptionalString(job.name), desired.name)) { patch.name = desired.name; } - if (!compareOptionalStrings(normalizeTrimmedString(job.description), desired.description)) { + if (!compareOptionalStrings(normalizeOptionalString(job.description), desired.description)) { patch.description = desired.description; } if (job.enabled !== true) { patch.enabled = true; } - const scheduleKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.schedule?.kind)); - const scheduleExpr = normalizeTrimmedString(job.schedule?.expr); - const scheduleTz = normalizeTrimmedString(job.schedule?.tz); + const scheduleKind = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.schedule?.kind)); + const scheduleExpr = normalizeOptionalString(job.schedule?.expr); + const scheduleTz = normalizeOptionalString(job.schedule?.tz); if ( scheduleKind !== "cron" || !compareOptionalStrings(scheduleExpr, desired.schedule.expr) || @@ -318,16 +315,16 @@ function buildManagedDreamingPatch( patch.schedule = desired.schedule; } - const sessionTarget = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.sessionTarget)); + const sessionTarget = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.sessionTarget)); if (sessionTarget !== desired.sessionTarget) { patch.sessionTarget = desired.sessionTarget; } - const wakeMode = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.wakeMode)); + const wakeMode = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.wakeMode)); if (wakeMode !== "now") { patch.wakeMode = "now"; } - const payloadKind = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.payload?.kind)); + const payloadKind = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.payload?.kind)); const payloadToken = resolveManagedDreamingPayloadToken(job.payload); const desiredPayloadToken = desired.payload.kind === "systemEvent" ? desired.payload.text : desired.payload.message; @@ -339,7 +336,7 @@ function buildManagedDreamingPatch( if (payloadNeedsUpdate) { patch.payload = desired.payload; } - const deliveryMode = normalizeLowercaseStringOrEmpty(normalizeTrimmedString(job.delivery?.mode)); + const deliveryMode = normalizeLowercaseStringOrEmpty(normalizeOptionalString(job.delivery?.mode)); if (deliveryMode !== "none") { patch.delivery = desired.delivery; } @@ -386,7 +383,7 @@ function resolveCronServiceFromGatewayContext(context: { getCron?: () => unknown } function resolveDreamingTriggerSessionKeys(sessionKey?: string): string[] { - const normalized = normalizeTrimmedString(sessionKey); + const normalized = normalizeOptionalString(sessionKey); if (!normalized) { return []; } @@ -409,7 +406,7 @@ function hasPendingManagedDreamingCronEvent(sessionKey?: string): boolean { peekSystemEventEntries(candidateSessionKey).some( (event) => event.contextKey?.startsWith("cron:") === true && - normalizeTrimmedString(event.text) === DREAMING_SYSTEM_EVENT_TEXT, + normalizeOptionalString(event.text) === DREAMING_SYSTEM_EVENT_TEXT, ), ); } @@ -573,7 +570,7 @@ async function runShortTermDreamingPromotionIfTriggered(params: { const recencyHalfLifeDays = params.config.recencyHalfLifeDays ?? DEFAULT_MEMORY_DREAMING_RECENCY_HALF_LIFE_DAYS; - const fallbackWorkspaceDir = normalizeTrimmedString(params.workspaceDir); + const fallbackWorkspaceDir = normalizeOptionalString(params.workspaceDir); // Narrative subagent sessions live in per-agent SQLite stores, so every swept workspace // carries its owning agent. The triggering agent owns whatever the roster cannot attribute. const triggerAgentId = normalizeLowercaseStringOrEmpty(params.agentId); diff --git a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts index 1059c18d6953..e50a2f5fc91d 100644 --- a/extensions/memory-core/src/migration/doctor-memory-sidecar.ts +++ b/extensions/memory-core/src/migration/doctor-memory-sidecar.ts @@ -11,6 +11,7 @@ import { legacyStateFileExists, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; // sqlite-runtime re-exports the agent-db/kysely graph; keep it lazy so doctor // enumeration does not cold-load it with this closure. import { @@ -28,10 +29,10 @@ type MemoryFtsTokenizer = "unicode61" | "trigram"; function resolveConfiguredAgentIds(config: unknown): string[] { const cfg = config as { agents?: { entries?: unknown; list?: unknown } }; - const entries = asRecord(cfg.agents?.entries); + const entries = asOptionalObjectRecord(cfg.agents?.entries); const listedIds = Array.isArray(cfg.agents?.list) ? cfg.agents.list.flatMap((entry) => { - const id = asRecord(entry)?.id; + const id = asOptionalObjectRecord(entry)?.id; return typeof id === "string" ? [id] : []; }) : []; @@ -39,46 +40,44 @@ function resolveConfiguredAgentIds(config: unknown): string[] { return ids.size > 0 ? [...ids] : [normalizeAgentId(undefined)]; } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function readAgentMemorySearch( config: unknown, agentId: string, ): Record | undefined { - const agents = asRecord(asRecord(config)?.agents); - const keyedEntries = asRecord(agents?.entries); + const agents = asOptionalObjectRecord(asOptionalObjectRecord(config)?.agents); + const keyedEntries = asOptionalObjectRecord(agents?.entries); const keyedEntry = keyedEntries ? Object.entries(keyedEntries).find(([id]) => normalizeAgentId(id) === agentId)?.[1] : undefined; - const keyedSearch = asRecord(asRecord(asRecord(keyedEntry)?.memory)?.search); + const keyedSearch = asOptionalObjectRecord( + asOptionalObjectRecord(asOptionalObjectRecord(keyedEntry)?.memory)?.search, + ); if (keyedSearch) { return keyedSearch; } const entries = Array.isArray(agents?.list) ? agents.list : []; const entry = entries - .map(asRecord) + .map(asOptionalObjectRecord) .find( (candidate) => normalizeAgentId(typeof candidate?.id === "string" ? candidate.id : undefined) === agentId, ); - return asRecord(asRecord(entry?.memory)?.search); + return asOptionalObjectRecord(asOptionalObjectRecord(entry?.memory)?.search); } function readMemorySearchLayers(config: unknown, agentId: string): Record[] { - const cfg = asRecord(config); + const cfg = asOptionalObjectRecord(config); return [ readAgentMemorySearch(config, agentId), - asRecord(asRecord(cfg?.memory)?.search), + asOptionalObjectRecord(asOptionalObjectRecord(cfg?.memory)?.search), // Doctor still inspects the retired root shape to migrate its persisted sidecar path. - asRecord(cfg?.memorySearch), + asOptionalObjectRecord(cfg?.memorySearch), ].filter((value): value is Record => value !== undefined); } function readStoreLayers(config: unknown, agentId: string): Record[] { return readMemorySearchLayers(config, agentId).flatMap((search) => { - const store = asRecord(search.store); + const store = asOptionalObjectRecord(search.store); return store ? [store] : []; }); } @@ -93,7 +92,7 @@ function readNestedStoreLayers( key: string, ): Record[] { return readStoreLayers(config, agentId).flatMap((store) => { - const nested = asRecord(store[key]); + const nested = asOptionalObjectRecord(store[key]); return nested ? [nested] : []; }); } diff --git a/extensions/memory-core/src/migration/dreaming-state-comparison.ts b/extensions/memory-core/src/migration/dreaming-state-comparison.ts index 6af64346c981..126190175edf 100644 --- a/extensions/memory-core/src/migration/dreaming-state-comparison.ts +++ b/extensions/memory-core/src/migration/dreaming-state-comparison.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeDailyIngestionState, normalizeSessionIngestionState, @@ -41,12 +42,6 @@ function targetNamespacesForSource(label: string): string[] { ]; } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - async function memoryCoreLegacyTargetHasRows(source: LegacyDreamingSource): Promise { const counts = await Promise.all( targetNamespacesForSource(source.label).map( @@ -137,7 +132,7 @@ async function memoryCoreLegacySourceMatchesCanonical( const canonical = normalizeShortTermRecallStore(canonicalRaw, updatedAt); const fallbackCandidates = new Set([updatedAt]); for (const row of entryRows) { - const value = asRecord(row.value); + const value = asOptionalRecord(row.value); for (const key of ["firstRecalledAt", "lastRecalledAt"] as const) { if (typeof value?.[key] === "string") { fallbackCandidates.add(value[key]); diff --git a/extensions/memory-core/src/session-backfill-gateway.ts b/extensions/memory-core/src/session-backfill-gateway.ts index ab3c645cff9b..abeeee4db906 100644 --- a/extensions/memory-core/src/session-backfill-gateway.ts +++ b/extensions/memory-core/src/session-backfill-gateway.ts @@ -60,7 +60,7 @@ function assertOnlyKeys(params: Record, allowed: ReadonlySet, key: "from" | "to") { +function readOptionalSessionBoundary(params: Record, key: "from" | "to") { const raw = params[key]; if (raw !== undefined && typeof raw !== "string") { throw new Error(`${key} must be a string.`); @@ -74,8 +74,8 @@ function readGatewayParams(value: unknown): SessionBackfillGatewayParams { const agentId = normalizeAgentId(readStringParam(params, "agentId", { required: true })); const selection = normalizeSessionBackfillSelection( { - from: readOptionalString(params, "from"), - to: readOptionalString(params, "to"), + from: readOptionalSessionBoundary(params, "from"), + to: readOptionalSessionBoundary(params, "to"), limitDays: readPositiveIntegerParam(params, "limitDays"), }, { from: "from", to: "to", limitDays: "limitDays" }, diff --git a/extensions/memory-core/src/short-term-promotion-store.ts b/extensions/memory-core/src/short-term-promotion-store.ts index fb65484fac73..7876161e0f1b 100644 --- a/extensions/memory-core/src/short-term-promotion-store.ts +++ b/extensions/memory-core/src/short-term-promotion-store.ts @@ -1,6 +1,6 @@ import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { sleep } from "openclaw/plugin-sdk/runtime-env"; -import { asRecord } from "./dreaming-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { SHORT_TERM_LOCK_MAX_ENTRIES, SHORT_TERM_LOCK_NAMESPACE, @@ -163,17 +163,17 @@ export function normalizeShortTermPhaseSignalStore( raw: unknown, nowIso: string, ): ShortTermPhaseSignalStore { - const record = asRecord(raw); + const record = asNullableRecord(raw); if (!record) { return emptyPhaseSignalStore(nowIso); } - const entriesRaw = asRecord(record?.entries); + const entriesRaw = asNullableRecord(record?.entries); if (!entriesRaw) { return emptyPhaseSignalStore(nowIso); } const entries: Record = {}; for (const [mapKey, value] of Object.entries(entriesRaw)) { - const entry = asRecord(value); + const entry = asNullableRecord(value); if (!entry) { continue; } diff --git a/extensions/memory-core/src/test-helpers.ts b/extensions/memory-core/src/test-helpers.ts index 4d2aef718ac5..27ba97ac8b93 100644 --- a/extensions/memory-core/src/test-helpers.ts +++ b/extensions/memory-core/src/test-helpers.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; import { createPluginStateKeyedStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { afterAll, beforeAll } from "vitest"; import { @@ -53,12 +54,6 @@ type ShortTermLockEntry = { acquiredAt: number; }; -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - async function readShortTermStoreEntries(params: { namespace: string; workspaceDir: string; @@ -88,8 +83,8 @@ async function writeRawShortTermStore(params: { namespace: string; metaKey: "recall" | "phase"; }): Promise { - const record = asRecord(params.raw); - const entries = asRecord(record?.entries); + const record = asOptionalRecord(params.raw); + const entries = asOptionalRecord(record?.entries); await Promise.all([ writeMemoryCoreWorkspaceEntries({ namespace: params.namespace, diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 61090013474a..8adef63a76fe 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -26,7 +26,7 @@ import { resolveMemoryDeepDreamingConfig, } from "openclaw/plugin-sdk/memory-core-host-status"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; -import { asRecord } from "./dreaming-shared.js"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { MemoryCoreAcquireLocalService } from "./memory/embedding-local-service.js"; import { DEFAULT_MEMORY_SEARCH_TIMEOUT_MS, @@ -161,7 +161,7 @@ const PAUSED_MEMORY_INDEX_ACTION = "Tell the user to run: openclaw memory status --index or openclaw memory index --force."; function resolvePausedMemoryIndexIdentityReason(status: { custom?: unknown }): string | undefined { - const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity); + const indexIdentity = asNullableRecord(asNullableRecord(status.custom)?.indexIdentity); if (indexIdentity?.status !== "mismatched" && indexIdentity?.status !== "missing") { return undefined; } diff --git a/extensions/memory-lancedb/doctor-contract-api.ts b/extensions/memory-lancedb/doctor-contract-api.ts index fcb28e324bcd..d488f28e9bad 100644 --- a/extensions/memory-lancedb/doctor-contract-api.ts +++ b/extensions/memory-lancedb/doctor-contract-api.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-scope-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { hasAgentScopeColumn, memoryAgentPredicate, @@ -82,12 +83,6 @@ export function resolveMemoryLanceDbPluginRoot(moduleUrl: string): string { const DEFAULT_PLUGIN_ROOT = resolveMemoryLanceDbPluginRoot(import.meta.url); -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function resolveHome(env: NodeJS.ProcessEnv): string { return env.HOME?.trim() || os.homedir(); } @@ -97,7 +92,7 @@ function resolveConfiguredDbPath( env: NodeJS.ProcessEnv, pluginRoot: string, ): string { - const pluginConfig = asRecord(config.plugins?.entries?.["memory-lancedb"]?.config); + const pluginConfig = asOptionalRecord(config.plugins?.entries?.["memory-lancedb"]?.config); const configured = typeof pluginConfig?.dbPath === "string" ? pluginConfig.dbPath.trim() : ""; if (!configured) { return path.join(resolveHome(env), ".openclaw", "memory", "lancedb"); @@ -116,8 +111,8 @@ function resolveStorageOptions( config: OpenClawConfig, env: NodeJS.ProcessEnv, ): Record | undefined { - const pluginConfig = asRecord(config.plugins?.entries?.["memory-lancedb"]?.config); - const rawOptions = asRecord(pluginConfig?.storageOptions); + const pluginConfig = asOptionalRecord(config.plugins?.entries?.["memory-lancedb"]?.config); + const rawOptions = asOptionalRecord(pluginConfig?.storageOptions); if (!rawOptions) { return undefined; } diff --git a/extensions/memory-lancedb/embeddings.ts b/extensions/memory-lancedb/embeddings.ts index 94f681ad22d6..79b969b6453e 100644 --- a/extensions/memory-lancedb/embeddings.ts +++ b/extensions/memory-lancedb/embeddings.ts @@ -10,7 +10,7 @@ import type { MemoryEmbeddingProvider } from "openclaw/plugin-sdk/memory-core-ho import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env"; -import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawPluginApi } from "./api.js"; import type { MemoryConfig } from "./config.js"; @@ -147,7 +147,7 @@ class OpenAiCompatibleEmbeddings implements Embeddings { } function isEmbeddingDimensionsRejectedError(error: unknown): boolean { - const record = asRecord(error); + const record = asOptionalRecord(error); if (record?.status !== 400 && record?.status !== 422) { return false; } @@ -170,7 +170,7 @@ function isUnsupportedEmbeddingFieldError(details: string): boolean { } function stringifyEmbeddingApiError(error: unknown): string { - const record = asRecord(error); + const record = asOptionalRecord(error); const parts = error instanceof Error ? [error.message] : []; for (const value of [record?.code, record?.type, record?.param, record?.error]) { if (typeof value === "string" || typeof value === "number") { @@ -475,7 +475,7 @@ export async function runWithTimeout(params: { export function isMemoryRecallTimeoutError(error: unknown): boolean { let current: unknown = error; for (let depth = 0; depth < 3 && current !== undefined; depth += 1) { - const record = asRecord(current); + const record = asOptionalRecord(current); const name = current instanceof Error ? current.name : typeof record?.name === "string" ? record.name : ""; const message = diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts index aa7a8a36c1cc..c57e2d09a0a9 100644 --- a/extensions/memory-lancedb/index.ts +++ b/extensions/memory-lancedb/index.ts @@ -12,7 +12,7 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { readFiniteNumberParam, readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; import { isIncognitoSessionKey, normalizeAgentId } from "openclaw/plugin-sdk/routing"; -import { asOptionalRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { Type } from "typebox"; import { definePluginEntry, type OpenClawPluginApi } from "./api.js"; @@ -154,7 +154,7 @@ export default definePluginEntry({ ...(typeof cfg.embedding.dimensions === "number" ? { dimensions: cfg.embedding.dimensions } : {}), - ...asRecord(runtimePluginConfig.embedding), + ...asOptionalRecord(runtimePluginConfig.embedding), }, ...(cfg.dreaming ? { dreaming: cfg.dreaming } : {}), dbPath: cfg.dbPath, @@ -163,7 +163,7 @@ export default definePluginEntry({ captureMaxChars: cfg.captureMaxChars, recallMaxChars: cfg.recallMaxChars, ...(cfg.storageOptions ? { storageOptions: cfg.storageOptions } : {}), - ...asRecord(runtimePluginConfig), + ...asOptionalRecord(runtimePluginConfig), }); }; const readMemoryRecallCooldown = (agentId: string): { error: string } | undefined => { diff --git a/extensions/memory-lancedb/memory-policy.ts b/extensions/memory-lancedb/memory-policy.ts index 5e300a6fb0db..799fadd4097b 100644 --- a/extensions/memory-lancedb/memory-policy.ts +++ b/extensions/memory-lancedb/memory-policy.ts @@ -1,5 +1,5 @@ import { - asOptionalRecord as asRecord, + asOptionalRecord, normalizeLowercaseStringOrEmpty, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -17,7 +17,7 @@ export type AutoCaptureCursor = { }; export function extractUserTextContent(message: unknown): string[] { - const msgObj = asRecord(message); + const msgObj = asOptionalRecord(message); if (!msgObj || msgObj.role !== "user") { return []; } @@ -33,7 +33,7 @@ export function extractUserTextContent(message: unknown): string[] { const texts: string[] = []; for (const block of content) { - const blockObj = asRecord(block); + const blockObj = asOptionalRecord(block); if (blockObj?.type === "text" && typeof blockObj.text === "string") { texts.push(blockObj.text); } @@ -67,7 +67,7 @@ function normalizeMaxChars(value: number | undefined, fallback: number): number } export function messageFingerprint(message: unknown): string { - const msgObj = asRecord(message); + const msgObj = asOptionalRecord(message); if (!msgObj) { return `${typeof message}:${String(message)}`; } diff --git a/extensions/memory-wiki/src/agent-vault-isolation.test.ts b/extensions/memory-wiki/src/agent-vault-isolation.test.ts index 70b325b1e705..19560b658294 100644 --- a/extensions/memory-wiki/src/agent-vault-isolation.test.ts +++ b/extensions/memory-wiki/src/agent-vault-isolation.test.ts @@ -22,7 +22,7 @@ import { createWikiApplyTool, createWikiGetTool, createWikiSearchTool } from "./ const { createTempDir } = createMemoryWikiTestHarness(); -function asRecord(value: unknown): Record { +function assertToolDetailsRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Expected tool details object"); } @@ -132,7 +132,7 @@ describe("agent-scoped memory-wiki tools", () => { sourceIds: [`source.${agent.id}`], }, ); - const pagePath = asRecord(result.details).pagePath; + const pagePath = assertToolDetailsRecord(result.details).pagePath; if (typeof pagePath !== "string") { throw new Error("Expected wiki_apply to return pagePath"); } @@ -164,7 +164,7 @@ describe("agent-scoped memory-wiki tools", () => { query: agent.sentinel, }); expect(textContent(ownSearch)).toContain(agent.sentinel); - expect(asRecord(ownSearch.details).results).toEqual([ + expect(assertToolDetailsRecord(ownSearch.details).results).toEqual([ expect.objectContaining({ path: agent.pagePath }), ]); @@ -172,18 +172,18 @@ describe("agent-scoped memory-wiki tools", () => { query: foreignAgent.sentinel, }); expect(textContent(foreignSearch)).toBe("No wiki or memory results."); - expect(asRecord(foreignSearch.details).results).toEqual([]); + expect(assertToolDetailsRecord(foreignSearch.details).results).toEqual([]); const getTool = createWikiGetTool(agent.config, appConfig, { agentId: agent.id }); const ownGet = await getTool.execute(`get-own-${agent.id}`, { lookup: agent.pagePath }); expect(textContent(ownGet)).toContain(agent.sentinel); - expect(asRecord(ownGet.details).found).toBe(true); + expect(assertToolDetailsRecord(ownGet.details).found).toBe(true); const foreignGet = await getTool.execute(`get-foreign-${agent.id}`, { lookup: foreignAgent.pagePath, }); expect(textContent(foreignGet)).toBe(`Wiki page not found: ${foreignAgent.pagePath}`); - expect(asRecord(foreignGet.details).found).toBe(false); + expect(assertToolDetailsRecord(foreignGet.details).found).toBe(false); } clearMemoryPluginState(); @@ -218,7 +218,7 @@ describe("agent-scoped memory-wiki tools", () => { query: agent.sentinel, corpus: "wiki", }); - expect(asRecord(ownMemorySearch.details).results).toEqual([ + expect(assertToolDetailsRecord(ownMemorySearch.details).results).toEqual([ expect.objectContaining({ corpus: "wiki", path: agent.pagePath, @@ -233,7 +233,7 @@ describe("agent-scoped memory-wiki tools", () => { corpus: "wiki", }, ); - expect(asRecord(foreignMemorySearch.details).results).toEqual([]); + expect(assertToolDetailsRecord(foreignMemorySearch.details).results).toEqual([]); const memoryGet = createMemoryCoreTool({ factories: memoryCoreFactories, @@ -245,7 +245,7 @@ describe("agent-scoped memory-wiki tools", () => { path: agent.pagePath, corpus: "wiki", }); - expect(asRecord(ownMemoryGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(ownMemoryGet.details)).toMatchObject({ corpus: "wiki", path: agent.pagePath, text: expect.stringContaining(agent.sentinel), @@ -255,7 +255,7 @@ describe("agent-scoped memory-wiki tools", () => { path: foreignAgent.pagePath, corpus: "wiki", }); - expect(asRecord(foreignMemoryGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(foreignMemoryGet.details)).toMatchObject({ path: foreignAgent.pagePath, text: "", disabled: true, diff --git a/extensions/memory-wiki/src/config-compat.ts b/extensions/memory-wiki/src/config-compat.ts index 590c5e3d52ef..ae2517a7894c 100644 --- a/extensions/memory-wiki/src/config-compat.ts +++ b/extensions/memory-wiki/src/config-compat.ts @@ -1,4 +1,5 @@ // Memory Wiki helper module supports config compat behavior. +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { OpenClawConfig } from "../api.js"; type LegacyConfigRule = { @@ -7,14 +8,8 @@ type LegacyConfigRule = { match: (value: unknown) => boolean; }; -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function hasLegacyBridgeArtifactToggle(value: unknown): boolean { - return Object.hasOwn(asRecord(value) ?? {}, "readMemoryCore"); + return Object.hasOwn(asNullableRecord(value) ?? {}, "readMemoryCore"); } export const legacyConfigRules: LegacyConfigRule[] = [ @@ -30,23 +25,23 @@ export function migrateMemoryWikiLegacyConfig(config: OpenClawConfig): { config: OpenClawConfig; changes: string[]; } | null { - const rawEntry = asRecord(config.plugins?.entries?.["memory-wiki"]); - const rawPluginConfig = asRecord(rawEntry?.config); - const rawBridge = asRecord(rawPluginConfig?.bridge); + const rawEntry = asNullableRecord(config.plugins?.entries?.["memory-wiki"]); + const rawPluginConfig = asNullableRecord(rawEntry?.config); + const rawBridge = asNullableRecord(rawPluginConfig?.bridge); if (!rawBridge || !hasLegacyBridgeArtifactToggle(rawBridge)) { return null; } const nextConfig = structuredClone(config); - const nextPlugins = asRecord(nextConfig.plugins) ?? {}; + const nextPlugins = asNullableRecord(nextConfig.plugins) ?? {}; nextConfig.plugins = nextPlugins; - const nextEntries = asRecord(nextPlugins.entries) ?? {}; + const nextEntries = asNullableRecord(nextPlugins.entries) ?? {}; nextPlugins.entries = nextEntries; - const nextEntry = asRecord(nextEntries["memory-wiki"]) ?? {}; + const nextEntry = asNullableRecord(nextEntries["memory-wiki"]) ?? {}; nextEntries["memory-wiki"] = nextEntry; - const nextPluginConfig = asRecord(nextEntry.config) ?? {}; + const nextPluginConfig = asNullableRecord(nextEntry.config) ?? {}; nextEntry.config = nextPluginConfig; - const nextBridge = asRecord(nextPluginConfig.bridge) ?? {}; + const nextBridge = asNullableRecord(nextPluginConfig.bridge) ?? {}; nextPluginConfig.bridge = nextBridge; const legacyValue = nextBridge.readMemoryCore; diff --git a/extensions/memory-wiki/src/corpus-supplement.visibility.test.ts b/extensions/memory-wiki/src/corpus-supplement.visibility.test.ts index 0a6903789b97..c8211f3fc2b6 100644 --- a/extensions/memory-wiki/src/corpus-supplement.visibility.test.ts +++ b/extensions/memory-wiki/src/corpus-supplement.visibility.test.ts @@ -49,7 +49,7 @@ async function writeBridgePage(params: { ); } -function asRecord(value: unknown): Record { +function assertToolDetailsRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("Expected tool details object"); } @@ -217,29 +217,29 @@ describe("memory-wiki corpus supplement visibility", () => { corpus: "wiki", }); - expect(asRecord(foreignGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(foreignGet.details)).toMatchObject({ text: "", disabled: true, error: "wiki corpus result not found", }); - expect(asRecord(unownedGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(unownedGet.details)).toMatchObject({ text: "", disabled: true, error: "wiki corpus result not found", }); - expect(asRecord(ownGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(ownGet.details)).toMatchObject({ path: "sources/main-private.md", text: expect.stringContaining("REDACTED-OWN-MARKER"), }); - expect(asRecord(foreignSearch.details).results).toEqual([]); - expect(asRecord(ownSearch.details).results).toEqual([ + expect(assertToolDetailsRecord(foreignSearch.details).results).toEqual([]); + expect(assertToolDetailsRecord(ownSearch.details).results).toEqual([ expect.objectContaining({ path: "sources/main-private.md" }), ]); - expect(asRecord(openForeignGet.details)).toMatchObject({ + expect(assertToolDetailsRecord(openForeignGet.details)).toMatchObject({ path: "sources/secondary-private.md", text: expect.stringContaining("REDACTED-FOREIGN-MARKER"), }); - expect(asRecord(openForeignSearch.details).results).toEqual([ + expect(assertToolDetailsRecord(openForeignSearch.details).results).toEqual([ expect.objectContaining({ path: "sources/secondary-private.md" }), ]); } finally { diff --git a/extensions/microsoft/speech-provider.ts b/extensions/microsoft/speech-provider.ts index 508b5d9bbe07..2742f8607a8a 100644 --- a/extensions/microsoft/speech-provider.ts +++ b/extensions/microsoft/speech-provider.ts @@ -20,11 +20,12 @@ import type { SpeechProviderPlugin, SpeechVoiceOption, } from "openclaw/plugin-sdk/speech"; -import { asBoolean, asFiniteNumber, asObject, trimToUndefined } from "openclaw/plugin-sdk/speech"; +import { asBoolean, asFiniteNumber, trimToUndefined } from "openclaw/plugin-sdk/speech"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { edgeTTS, inferEdgeExtension } from "./tts.js"; @@ -50,10 +51,10 @@ type MicrosoftProviderConfig = { function normalizeMicrosoftProviderConfig( rawConfig: Record, ): MicrosoftProviderConfig { - const providers = asObject(rawConfig.providers); - const rawEdge = asObject(rawConfig.edge); - const rawMicrosoft = asObject(rawConfig.microsoft); - const rawProviderMicrosoft = asObject(providers?.microsoft); + const providers = asOptionalRecord(rawConfig.providers); + const rawEdge = asOptionalRecord(rawConfig.edge); + const rawMicrosoft = asOptionalRecord(rawConfig.microsoft); + const rawProviderMicrosoft = asOptionalRecord(providers?.microsoft); const raw = { ...rawEdge, ...rawMicrosoft, ...rawProviderMicrosoft }; const outputFormat = trimToUndefined(raw.outputFormat); return { @@ -166,12 +167,12 @@ async function listMicrosoftVoices( const voices = await readProviderJsonResponse(response, "microsoft.speech-voices"); return Array.isArray(voices) ? voices.flatMap((value) => { - const voice = asObject(value); + const voice = asOptionalRecord(value); const id = trimToUndefined(voice?.ShortName); if (!voice || !id) { return []; } - const voiceTag = asObject(voice.VoiceTag); + const voiceTag = asOptionalRecord(voice.VoiceTag); const categories = readMicrosoftVoiceTagStrings(voiceTag?.ContentCategories); const personalities = readMicrosoftVoiceTagStrings(voiceTag?.VoicePersonalities); return [ diff --git a/extensions/migrate-claude/config.ts b/extensions/migrate-claude/config.ts index 78fbfbb3068e..37b10fd9e677 100644 --- a/extensions/migrate-claude/config.ts +++ b/extensions/migrate-claude/config.ts @@ -8,7 +8,8 @@ import { MIGRATION_REASON_TARGET_EXISTS, } from "openclaw/plugin-sdk/migration"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; -import { childRecord, isRecord, readJsonObject, sanitizeName } from "./helpers.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { childRecord, readJsonObject, sanitizeName } from "./helpers.js"; import type { ClaudeSource } from "./source.js"; type MappedMcpSource = { diff --git a/extensions/migrate-claude/helpers.ts b/extensions/migrate-claude/helpers.ts index 16c9bb6c0e00..f3cef8c82163 100644 --- a/extensions/migrate-claude/helpers.ts +++ b/extensions/migrate-claude/helpers.ts @@ -8,7 +8,7 @@ import { } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime"; -import { isRecord as sharedIsRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export function resolveHomePath(input: string): string { const trimmed = input.trim(); @@ -64,8 +64,6 @@ export async function readJsonObject( } } -export const isRecord = sharedIsRecord; - export function childRecord( root: Record | undefined, key: string, diff --git a/extensions/migrate-hermes/auth-source.ts b/extensions/migrate-hermes/auth-source.ts index ea9c408becd8..216a74141cca 100644 --- a/extensions/migrate-hermes/auth-source.ts +++ b/extensions/migrate-hermes/auth-source.ts @@ -1,7 +1,8 @@ // Hermes-native auth discovery and reauthentication planning. import { createMigrationManualItem } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; -import { isRecord, readString, readText } from "./helpers.js"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { readText } from "./helpers.js"; import type { HermesSource } from "./source.js"; const HERMES_OPENAI_CODEX_SOURCE_PROVIDER_ID = "openai-codex"; @@ -48,8 +49,8 @@ function readHermesProviderCandidate( ? providers[HERMES_OPENAI_CODEX_SOURCE_PROVIDER_ID] : undefined; const tokens = isRecord(provider?.tokens) ? provider.tokens : undefined; - const access = readString(tokens?.access_token); - const refresh = readString(tokens?.refresh_token); + const access = normalizeOptionalString(tokens?.access_token); + const refresh = normalizeOptionalString(tokens?.refresh_token); if (!access || !refresh) { return undefined; } @@ -76,8 +77,8 @@ function readHermesPoolCandidates( if (!isRecord(entry)) { return []; } - const access = readString(entry.access_token); - const refresh = readString(entry.refresh_token); + const access = normalizeOptionalString(entry.access_token); + const refresh = normalizeOptionalString(entry.refresh_token); if (!access || !refresh) { return []; } @@ -87,7 +88,7 @@ function readHermesPoolCandidates( refresh, sourceKind: "hermes-auth-json" as const, sourceSlot: "pool" as const, - sourceLabel: readString(entry.label) ?? "Hermes OpenAI Codex credential pool", + sourceLabel: normalizeOptionalString(entry.label) ?? "Hermes OpenAI Codex credential pool", sourcePath, updatedAt: readTimestamp(entry.last_refresh) ?? readTimestamp(entry.last_status_at), }, @@ -142,7 +143,9 @@ async function readHermesOAuthProviderIds(authPath: string | undefined): Promise ? Object.entries(parsed.credential_pool).flatMap(([provider, entries]) => Array.isArray(entries) && entries.some( - (entry) => isRecord(entry) && readString(entry.auth_type)?.toLowerCase() === "oauth", + (entry) => + isRecord(entry) && + normalizeOptionalString(entry.auth_type)?.toLowerCase() === "oauth", ) ? [provider] : [], diff --git a/extensions/migrate-hermes/auth.ts b/extensions/migrate-hermes/auth.ts index 39a490c27c42..a57bfca77e1e 100644 --- a/extensions/migrate-hermes/auth.ts +++ b/extensions/migrate-hermes/auth.ts @@ -27,6 +27,7 @@ import { applyAgentDefaultModelPrimary, resolveAgentModelPrimaryValue, } from "openclaw/plugin-sdk/provider-onboard"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyAuthProfileConfigWithConflictCheck, hasAuthProfileConfigConflict, @@ -38,7 +39,7 @@ import { readHermesCodexAuthCandidates, type HermesCodexAuthCandidate, } from "./auth-source.js"; -import { isRecord, readString, readText } from "./helpers.js"; +import { readText } from "./helpers.js"; import { HERMES_REASON_AUTH_PROFILE_EXISTS, HERMES_REASON_AUTH_PROFILE_WRITE_FAILED, @@ -101,9 +102,9 @@ async function readOpenCodeOpenAICandidates( return []; } const openai = isRecord(parsed.openai) ? parsed.openai : undefined; - const access = readString(openai?.access); - const accountId = readString(openai?.accountId); - const refresh = readString(openai?.refresh); + const access = normalizeOptionalString(openai?.access); + const accountId = normalizeOptionalString(openai?.accountId); + const refresh = normalizeOptionalString(openai?.refresh); if (!access || !refresh) { return []; } @@ -147,7 +148,7 @@ function readProviderAuthModelConfigs(result: ProviderAuthResult): AgentDefaultM if (isRecord(models)) { return { ...models }; } - const defaultModel = readString(result.defaultModel) ?? OPENAI_CODEX_DEFAULT_MODEL; + const defaultModel = normalizeOptionalString(result.defaultModel) ?? OPENAI_CODEX_DEFAULT_MODEL; return { [defaultModel]: {} }; } diff --git a/extensions/migrate-hermes/config-env.ts b/extensions/migrate-hermes/config-env.ts index 777d6783b496..e422ce4c2cd6 100644 --- a/extensions/migrate-hermes/config-env.ts +++ b/extensions/migrate-hermes/config-env.ts @@ -1,5 +1,5 @@ // Hermes environment interpolation shared by provider and MCP config. -import { isRecord } from "./helpers.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export const MCP_ENV_REFERENCE_RE = /\$\{([^}]+)\}/gu; diff --git a/extensions/migrate-hermes/config-mcp.ts b/extensions/migrate-hermes/config-mcp.ts index 4804d2b9c036..7450a73e3ab2 100644 --- a/extensions/migrate-hermes/config-mcp.ts +++ b/extensions/migrate-hermes/config-mcp.ts @@ -1,18 +1,19 @@ // Hermes MCP config mapping and manual follow-up planning. import { createMigrationManualItem } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; -import { parseBooleanValue } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean, + isRecord, + normalizeOptionalString, + parseBooleanValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { mcpValueHasEnvReferences, resolveMcpEnvReferences } from "./config-env.js"; import { readPositiveNumber } from "./config-provider-contract.js"; -import { isRecord, readString, sanitizeName } from "./helpers.js"; +import { sanitizeName } from "./helpers.js"; const MCP_RESOURCE_UTILITY_TOOLS = ["resources_list", "resources_read"] as const; const MCP_PROMPT_UTILITY_TOOLS = ["prompts_list", "prompts_get"] as const; -function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function readPositiveNumeric(value: unknown): number | undefined { if (typeof value === "number") { return readPositiveNumber(value); @@ -81,13 +82,13 @@ function mapHermesClientCertificate(value: Record): { clientKey?: string; } { const cert = value.clientCert ?? value.client_cert; - const key = readString(value.clientKey) ?? readString(value.client_key); + const key = normalizeOptionalString(value.clientKey) ?? normalizeOptionalString(value.client_key); if (Array.isArray(cert) && cert.length === 2) { - const certPath = readString(cert[0]); - const keyPath = readString(cert[1]); + const certPath = normalizeOptionalString(cert[0]); + const keyPath = normalizeOptionalString(cert[1]); return certPath && keyPath ? { clientCert: certPath, clientKey: keyPath } : {}; } - const certPath = readString(cert); + const certPath = normalizeOptionalString(cert); return certPath && key ? { clientCert: certPath, clientKey: key } : {}; } @@ -121,7 +122,7 @@ function mapHermesMcpOauth(value: Record): Record = {}; for (const key of ["authProfileId", "scope", "redirectUrl", "clientMetadataUrl"]) { - const fieldValue = readString(oauth[key]); + const fieldValue = normalizeOptionalString(oauth[key]); if (fieldValue) { mapped[key] = fieldValue; } @@ -151,12 +152,12 @@ export function mapMcpServer( } } } - const transport = readString(value.transport) ?? readString(value.type); + const transport = normalizeOptionalString(value.transport) ?? normalizeOptionalString(value.type); if (transport === "http" || transport === "streamable-http") { next.transport = "streamable-http"; } else if (transport === "sse" || transport === "stdio") { next.transport = transport; - } else if (!transport && readString(next.url)) { + } else if (!transport && normalizeOptionalString(next.url)) { next.transport = "streamable-http"; } // Canonical timeout fields are finite().positive(); drop non-positive or @@ -179,11 +180,11 @@ export function mapMcpServer( ) { next.requestTimeoutMs = requestTimeoutSeconds * 1_000; } - next.supportsParallelToolCalls = readBoolean( + next.supportsParallelToolCalls = asBoolean( value.supportsParallelToolCalls ?? value.supports_parallel_tool_calls, ); - next.sslVerify = readBoolean(value.sslVerify ?? value.ssl_verify); - next.auth = readString(value.auth) === "oauth" ? "oauth" : undefined; + next.sslVerify = asBoolean(value.sslVerify ?? value.ssl_verify); + next.auth = normalizeOptionalString(value.auth) === "oauth" ? "oauth" : undefined; next.oauth = mapHermesMcpOauth(value); Object.assign(next, mapHermesClientCertificate(value)); const toolFilter = mapHermesToolFilter(value); @@ -201,7 +202,9 @@ export function mapMcpServer( const mapped = Object.fromEntries( Object.entries(next).filter(([, entry]) => entry !== undefined), ); - return readString(mapped.command) || readString(mapped.url) ? mapped : {}; + return normalizeOptionalString(mapped.command) || normalizeOptionalString(mapped.url) + ? mapped + : {}; } export function mcpManualItems(params: { @@ -256,7 +259,7 @@ export function mcpManualItems(params: { } const cert = raw.clientCert ?? raw.client_cert; - const key = readString(raw.clientKey) ?? readString(raw.client_key); + const key = normalizeOptionalString(raw.clientKey) ?? normalizeOptionalString(raw.client_key); if (Array.isArray(cert) && cert.length === 3) { add( "client-cert-password", @@ -266,8 +269,11 @@ export function mcpManualItems(params: { } else if ( (cert !== undefined || key !== undefined) && !( - (Array.isArray(cert) && cert.length === 2 && readString(cert[0]) && readString(cert[1])) || - (readString(cert) && key) + (Array.isArray(cert) && + cert.length === 2 && + normalizeOptionalString(cert[0]) && + normalizeOptionalString(cert[1])) || + (normalizeOptionalString(cert) && key) ) ) { add( @@ -284,7 +290,7 @@ export function mcpManualItems(params: { ); } - const transport = readString(raw.transport) ?? readString(raw.type); + const transport = normalizeOptionalString(raw.transport) ?? normalizeOptionalString(raw.type); if (transport && !["http", "streamable-http", "sse", "stdio"].includes(transport)) { add( "transport", @@ -293,7 +299,7 @@ export function mcpManualItems(params: { ); } - const auth = readString(raw.auth); + const auth = normalizeOptionalString(raw.auth); if (auth && auth !== "oauth") { add( "auth", diff --git a/extensions/migrate-hermes/config-provider-contract.ts b/extensions/migrate-hermes/config-provider-contract.ts index 6d1c1417c6d2..783599c40553 100644 --- a/extensions/migrate-hermes/config-provider-contract.ts +++ b/extensions/migrate-hermes/config-provider-contract.ts @@ -1,10 +1,11 @@ +// Hermes provider config contract parsing and normalization. +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { MCP_ENV_REFERENCE_RE, mcpValueHasEnvReferences, resolveMcpEnvReferences, } from "./config-env.js"; -// Hermes provider config contract parsing and normalization. -import { childRecord, isRecord, readString, readStringArray } from "./helpers.js"; +import { childRecord, readStringArray } from "./helpers.js"; import { normalizeHermesCustomProviderId, normalizeHermesProviderId } from "./model.js"; type OpenClawModelApi = @@ -170,7 +171,7 @@ export function resolveProviderApi( raw: Record, providerId?: string, ): OpenClawModelApi | undefined { - const transport = readString(raw.transport) ?? readString(raw.api_mode); + const transport = normalizeOptionalString(raw.transport) ?? normalizeOptionalString(raw.api_mode); const sourceProvider = providerId?.trim().toLowerCase() ?? ""; if (sourceProvider === "openai-codex") { return "openai-chatgpt-responses"; @@ -180,10 +181,10 @@ export function resolveProviderApi( } const provider = sourceProvider ? normalizeHermesProviderId(sourceProvider) : ""; const baseUrl = - readString(raw.base_url) ?? - readString(raw.baseUrl) ?? - readString(raw.url) ?? - readString(raw.api); + normalizeOptionalString(raw.base_url) ?? + normalizeOptionalString(raw.baseUrl) ?? + normalizeOptionalString(raw.url) ?? + normalizeOptionalString(raw.api); let hostname = ""; let pathname = ""; try { @@ -238,7 +239,7 @@ function normalizeProviderBaseUrl(baseUrl: string, api: OpenClawModelApi): strin } export function readEnvReference(value: unknown): string | undefined { - const raw = readString(value); + const raw = normalizeOptionalString(value); const match = raw?.match(/^\$\{([^}]+)\}$/u); return match ? normalizeHermesEnvReferenceName(match[1] ?? "") : undefined; } @@ -251,10 +252,10 @@ function normalizeHermesEnvReferenceName(value: string): string | undefined { export function readProviderApiKeyEnv(raw: Record): string | undefined { return ( - readString(raw.key_env) ?? - readString(raw.api_key_env) ?? - readString(raw.apiKeyEnv) ?? - readString(raw.env) ?? + normalizeOptionalString(raw.key_env) ?? + normalizeOptionalString(raw.api_key_env) ?? + normalizeOptionalString(raw.apiKeyEnv) ?? + normalizeOptionalString(raw.env) ?? readEnvReference(raw.api_key) ); } @@ -302,9 +303,9 @@ export function collectProviderModels(raw: Record): HermesModel }); } for (const modelId of [ - readString(raw.default_model), - readString(raw.default), - readString(raw.model), + normalizeOptionalString(raw.default_model), + normalizeOptionalString(raw.default), + normalizeOptionalString(raw.model), ]) { if (modelId && !models.has(modelId)) { models.set(modelId, { id: modelId, ...rootMetadata }); @@ -347,10 +348,10 @@ export function readProviderBaseUrl( env: Record, ): { baseUrl?: string; sensitive: boolean; unresolved: boolean } { const value = - readString(raw.base_url) ?? - readString(raw.baseUrl) ?? - readString(raw.url) ?? - readString(raw.api); + normalizeOptionalString(raw.base_url) ?? + normalizeOptionalString(raw.baseUrl) ?? + normalizeOptionalString(raw.url) ?? + normalizeOptionalString(raw.api); if (!value) { return { sensitive: false, unresolved: false }; } diff --git a/extensions/migrate-hermes/config-providers.ts b/extensions/migrate-hermes/config-providers.ts index 8743e58e2cc9..319150e867b0 100644 --- a/extensions/migrate-hermes/config-providers.ts +++ b/extensions/migrate-hermes/config-providers.ts @@ -1,6 +1,7 @@ // Hermes provider config collection and migration planning. import { createMigrationManualItem } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { HERMES_TRANSPORTS, collectProviderModels, @@ -15,7 +16,7 @@ import { resolveProviderApi, type HermesProviderConfig, } from "./config-provider-contract.js"; -import { childRecord, isRecord, readString, sanitizeName } from "./helpers.js"; +import { childRecord, sanitizeName } from "./helpers.js"; import { normalizeHermesCustomProviderId, resolveHermesConfiguredProviderId } from "./model.js"; type HermesProviderSecretBinding = { @@ -81,7 +82,7 @@ export function collectHermesProviders( if (!isRecord(raw)) { continue; } - const id = readString(raw.name) ?? readString(raw.id); + const id = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id); if (!id) { continue; } @@ -109,7 +110,7 @@ export function collectHermesProviders( const model = config.model; if (isRecord(model)) { - const rawProvider = readString(model.provider); + const rawProvider = normalizeOptionalString(model.provider); const resolvedBaseUrl = readProviderBaseUrl(model, env); const envBaseUrl = resolveHermesProviderBaseUrlEnv(rawProvider, env); const baseUrl = @@ -131,7 +132,7 @@ export function collectHermesProviders( }); } } else { - const rawProvider = readString(config.provider); + const rawProvider = normalizeOptionalString(config.provider); const baseUrl = resolveHermesProviderBaseUrlEnv(rawProvider, env) ?? resolveHermesImplicitBaseUrl(rawProvider); @@ -175,7 +176,7 @@ export function collectHermesProviderSecretBindings( if (!isRecord(raw)) { continue; } - const sourceProvider = readString(raw.name) ?? readString(raw.id); + const sourceProvider = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id); const envVar = readProviderApiKeyEnv(raw); if (sourceProvider && envVar) { bindings.push({ @@ -186,7 +187,8 @@ export function collectHermesProviderSecretBindings( } } const model = isRecord(config.model) ? config.model : undefined; - const selectedProvider = readString(model?.provider) ?? readString(config.provider); + const selectedProvider = + normalizeOptionalString(model?.provider) ?? normalizeOptionalString(config.provider); const selectedEnv = (model ? readProviderApiKeyEnv(model) : undefined) ?? resolveHermesProviderApiKeyEnv(selectedProvider); @@ -240,15 +242,17 @@ export function providerManualItems( if (!isRecord(raw)) { continue; } - const id = readString(raw.name) ?? readString(raw.id); + const id = normalizeOptionalString(raw.name) ?? normalizeOptionalString(raw.id); if (id && !currentProviderIds.has(normalizeHermesCustomProviderId(id))) { entries.push({ id, raw, source: `config.yaml:custom_providers.${id}` }); } } } if (isRecord(config.model)) { - const provider = readString(config.model.provider); - const baseUrl = readString(config.model.base_url) ?? readString(config.model.baseUrl); + const provider = normalizeOptionalString(config.model.provider); + const baseUrl = + normalizeOptionalString(config.model.base_url) ?? + normalizeOptionalString(config.model.baseUrl); if (baseUrl) { entries.push({ id: provider @@ -261,7 +265,8 @@ export function providerManualItems( } const items: MigrationItem[] = []; for (const { id, raw, source } of entries) { - const transport = readString(raw.transport) ?? readString(raw.api_mode); + const transport = + normalizeOptionalString(raw.transport) ?? normalizeOptionalString(raw.api_mode); const baseUrlConfig = readProviderBaseUrl(raw, env); const baseUrl = baseUrlConfig.baseUrl ?? resolveHermesImplicitBaseUrl(id); const headerConfig = readProviderHeaders(raw, env, includeSecrets); @@ -294,7 +299,7 @@ export function providerManualItems( }), ); } - if (readString(raw.api_key) && !readEnvReference(raw.api_key)) { + if (normalizeOptionalString(raw.api_key) && !readEnvReference(raw.api_key)) { items.push( createMigrationManualItem({ id: `manual:model-provider-inline-key:${sanitizeName(id)}`, diff --git a/extensions/migrate-hermes/config.ts b/extensions/migrate-hermes/config.ts index a00400f34d22..91b3d9eba795 100644 --- a/extensions/migrate-hermes/config.ts +++ b/extensions/migrate-hermes/config.ts @@ -7,6 +7,7 @@ import { hasMigrationConfigPatchConflict, } from "openclaw/plugin-sdk/migration"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { importsMcpSensitiveValues, mapMcpServer, mcpManualItems } from "./config-mcp.js"; import { providerConfig } from "./config-provider-contract.js"; import { @@ -14,7 +15,7 @@ import { collectHermesProviders, providerManualItems, } from "./config-providers.js"; -import { childRecord, isRecord, readString, sanitizeName } from "./helpers.js"; +import { childRecord, sanitizeName } from "./helpers.js"; function mapSkillEntries(config: Record): Record | undefined { const entries: Record = {}; @@ -38,7 +39,7 @@ export function buildConfigItems(params: { }): MigrationItem[] { const items: MigrationItem[] = []; const memory = childRecord(params.config, "memory"); - const memoryProvider = readString(memory.provider); + const memoryProvider = normalizeOptionalString(memory.provider); if (params.hasMemoryFiles || memoryProvider) { items.push( diff --git a/extensions/migrate-hermes/helpers.ts b/extensions/migrate-hermes/helpers.ts index 8bf1468c8969..6a251a332a36 100644 --- a/extensions/migrate-hermes/helpers.ts +++ b/extensions/migrate-hermes/helpers.ts @@ -10,10 +10,7 @@ import { } from "openclaw/plugin-sdk/migration"; import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry"; import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime"; -import { - isRecord as sharedIsRecord, - normalizeOptionalString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { parse as parseYaml } from "yaml"; const HOME_SHORTHAND_RE = /^~(?=$|[\\/])/u; @@ -57,8 +54,6 @@ export function parseHermesConfig(content: string | undefined): Record | undefined, key: string, @@ -67,8 +62,6 @@ export function childRecord( return isRecord(value) ? value : {}; } -export const readString = normalizeOptionalString; - export function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return []; diff --git a/extensions/migrate-hermes/items.ts b/extensions/migrate-hermes/items.ts index 5ff7f1e15188..a4b98a0ac302 100644 --- a/extensions/migrate-hermes/items.ts +++ b/extensions/migrate-hermes/items.ts @@ -6,7 +6,7 @@ import { markMigrationItemError, markMigrationItemSkipped, } from "openclaw/plugin-sdk/migration"; -import { isRecord, readString } from "./helpers.js"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; export const HERMES_REASON_ALREADY_CONFIGURED = "already configured"; export const HERMES_REASON_DEFAULT_MODEL_CONFIGURED = "default model already configured"; @@ -41,7 +41,7 @@ export function createHermesModelItem(params: { } export function readHermesModelDetails(item: MigrationItem): { model: string } | undefined { - const model = readString(item.details?.model); + const model = normalizeOptionalString(item.details?.model); return model ? { model } : undefined; } @@ -112,17 +112,17 @@ export function readHermesSecretDetails(item: MigrationItem): secretField?: string; } | undefined { - const envVar = readString(item.details?.envVar); - const provider = readString(item.details?.provider); - const profileId = readString(item.details?.profileId); + const envVar = normalizeOptionalString(item.details?.envVar); + const provider = normalizeOptionalString(item.details?.provider); + const profileId = normalizeOptionalString(item.details?.profileId); if (!provider || !profileId) { return undefined; } const mode = item.details?.mode === "token" ? "token" : undefined; - const sourceKind = readString(item.details?.sourceKind); - const sourceProvider = readString(item.details?.sourceProvider); - const sourceCredentialId = readString(item.details?.sourceCredentialId); - const secretField = readString(item.details?.secretField); + const sourceKind = normalizeOptionalString(item.details?.sourceKind); + const sourceProvider = normalizeOptionalString(item.details?.sourceProvider); + const sourceCredentialId = normalizeOptionalString(item.details?.sourceCredentialId); + const secretField = normalizeOptionalString(item.details?.secretField); return { ...(envVar ? { envVar } : {}), provider, diff --git a/extensions/migrate-hermes/model.ts b/extensions/migrate-hermes/model.ts index 2fc92a60a052..7b327536c00f 100644 --- a/extensions/migrate-hermes/model.ts +++ b/extensions/migrate-hermes/model.ts @@ -6,7 +6,10 @@ import { } from "openclaw/plugin-sdk/agent-runtime"; import { resolveMigrationConfigRuntime } from "openclaw/plugin-sdk/migration"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; -import { readString } from "./helpers.js"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { HERMES_REASON_ALREADY_CONFIGURED, HERMES_REASON_CONFIG_RUNTIME_UNAVAILABLE, @@ -147,30 +150,24 @@ function isRetiredHermesQwenProviderValue(value: string): boolean { } export function usesRetiredHermesQwenProvider(config: Record): boolean { - const model = asRecord(config.model); + const model = asOptionalRecord(config.model); return [ - readString(config.provider), + normalizeOptionalString(config.provider), typeof config.model === "string" ? config.model : undefined, - readString(model?.provider), - readString(model?.default), - readString(model?.model), - readString(config.default_model), - readString(config.model_name), + normalizeOptionalString(model?.provider), + normalizeOptionalString(model?.default), + normalizeOptionalString(model?.model), + normalizeOptionalString(config.default_model), + normalizeOptionalString(config.model_name), ].some((value) => value !== undefined && isRetiredHermesQwenProviderValue(value)); } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readBaseUrl(value: Record | undefined): string | undefined { return value - ? (readString(value.base_url) ?? - readString(value.baseUrl) ?? - readString(value.url) ?? - readString(value.api)) + ? (normalizeOptionalString(value.base_url) ?? + normalizeOptionalString(value.baseUrl) ?? + normalizeOptionalString(value.url) ?? + normalizeOptionalString(value.api)) : undefined; } @@ -179,8 +176,9 @@ function readKimiBaseUrl( provider: string, env: Record, ): string | undefined { - const model = asRecord(config.model); - const selectedProvider = readString(model?.provider) ?? readString(config.provider); + const model = asOptionalRecord(config.model); + const selectedProvider = + normalizeOptionalString(model?.provider) ?? normalizeOptionalString(config.provider); if ( selectedProvider && HERMES_DYNAMIC_KIMI_PROVIDER_IDS.has(normalizeHermesCustomProviderId(selectedProvider)) @@ -190,13 +188,13 @@ function readKimiBaseUrl( return modelBaseUrl; } } - const providers = asRecord(config.providers); + const providers = asOptionalRecord(config.providers); for (const [id, value] of Object.entries(providers ?? {})) { if ( normalizeHermesCustomProviderId(id) === normalizeHermesCustomProviderId(provider) && - asRecord(value) + asOptionalRecord(value) ) { - const providerBaseUrl = readBaseUrl(asRecord(value)); + const providerBaseUrl = readBaseUrl(asOptionalRecord(value)); if (providerBaseUrl) { return providerBaseUrl; } @@ -260,7 +258,7 @@ function hasExplicitHermesProvider(config: Record, provider: st return false; } const record = entry as Record; - const id = readString(record.name) ?? readString(record.id); + const id = normalizeOptionalString(record.name) ?? normalizeOptionalString(record.id); return id ? normalizeHermesCustomProviderId(id) === normalized : false; }) ); @@ -317,20 +315,23 @@ export function resolveHermesModelRef( const model = config.model; if (typeof model === "string" && model.trim()) { const rawModel = model.trim(); - const provider = readString(config.provider); + const provider = normalizeOptionalString(config.provider); return joinHermesProviderModel(config, provider, rawModel, env); } if (model && typeof model === "object" && !Array.isArray(model)) { const modelRecord = model as Record; - const rawModel = readString(modelRecord.default) ?? readString(modelRecord.model); + const rawModel = + normalizeOptionalString(modelRecord.default) ?? normalizeOptionalString(modelRecord.model); const hasCustomEndpoint = Boolean( - readString(modelRecord.base_url) ?? readString(modelRecord.baseUrl), + normalizeOptionalString(modelRecord.base_url) ?? normalizeOptionalString(modelRecord.baseUrl), ); - const provider = readString(modelRecord.provider) ?? (hasCustomEndpoint ? "custom" : undefined); + const provider = + normalizeOptionalString(modelRecord.provider) ?? (hasCustomEndpoint ? "custom" : undefined); return rawModel ? joinHermesProviderModel(config, provider, rawModel, env) : undefined; } - const rootModel = readString(config.default_model) ?? readString(config.model_name); - const rootProvider = readString(config.provider); + const rootModel = + normalizeOptionalString(config.default_model) ?? normalizeOptionalString(config.model_name); + const rootProvider = normalizeOptionalString(config.provider); return rootModel ? joinHermesProviderModel(config, rootProvider, rootModel, env) : undefined; } diff --git a/extensions/migrate-hermes/secrets.ts b/extensions/migrate-hermes/secrets.ts index 3c1a68b4f758..189f7515b8bb 100644 --- a/extensions/migrate-hermes/secrets.ts +++ b/extensions/migrate-hermes/secrets.ts @@ -5,6 +5,7 @@ import { } from "openclaw/plugin-sdk/agent-runtime"; import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry"; import { updateAuthProfileStoreWithLock } from "openclaw/plugin-sdk/provider-auth"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyAuthProfileConfigWithConflictCheck, hasAuthProfileConfigConflict, @@ -12,7 +13,7 @@ import { type HermesAuthProfileConfig, } from "./auth-config.js"; import { collectHermesProviderSecretBindings } from "./config-providers.js"; -import { isRecord, parseEnv, readString, readText, sanitizeName } from "./helpers.js"; +import { parseEnv, readText, sanitizeName } from "./helpers.js"; import { createHermesSecretItem, HERMES_REASON_AUTH_PROFILE_EXISTS, @@ -141,9 +142,9 @@ async function buildOpenCodeSecretCandidates( const opencode = isRecord(auth.opencode) ? auth.opencode : {}; const opencodeGo = isRecord(auth["opencode-go"]) ? auth["opencode-go"] : {}; const githubCopilot = isRecord(auth["github-copilot"]) ? auth["github-copilot"] : {}; - const githubCopilotEnterpriseUrl = readString(githubCopilot.enterpriseUrl); + const githubCopilotEnterpriseUrl = normalizeOptionalString(githubCopilot.enterpriseUrl); const candidates: SecretCandidate[] = []; - if (readString(opencode.key)) { + if (normalizeOptionalString(opencode.key)) { candidates.push({ id: "secret:opencode:opencode-auth-json", source: authPath, @@ -155,7 +156,7 @@ async function buildOpenCodeSecretCandidates( secretField: "key", }); } - if (readString(opencodeGo.key)) { + if (normalizeOptionalString(opencodeGo.key)) { candidates.push({ id: "secret:opencode-go:opencode-auth-json", source: authPath, @@ -168,7 +169,7 @@ async function buildOpenCodeSecretCandidates( }); } // OpenClaw's Copilot token profile cannot preserve OpenCode enterprise routing yet. - if (readString(githubCopilot.refresh) && !githubCopilotEnterpriseUrl) { + if (normalizeOptionalString(githubCopilot.refresh) && !githubCopilotEnterpriseUrl) { candidates.push({ id: "secret:github-copilot:opencode-auth-json", source: authPath, @@ -214,14 +215,14 @@ async function buildHermesPoolSecretCandidates( if (!isRecord(rawEntry)) { continue; } - const sourceCredentialId = readString(rawEntry.id); - const authType = readString(rawEntry.auth_type); - const source = readString(rawEntry.source); + const sourceCredentialId = normalizeOptionalString(rawEntry.id); + const authType = normalizeOptionalString(rawEntry.auth_type); + const source = normalizeOptionalString(rawEntry.source); if ( !sourceCredentialId || authType !== "api_key" || source !== "manual" || - !readString(rawEntry.access_token) + !normalizeOptionalString(rawEntry.access_token) ) { continue; } @@ -264,7 +265,7 @@ async function readSecretCandidateValue( return undefined; } const provider = isRecord(auth[sourceProvider]) ? auth[sourceProvider] : {}; - return readString(provider[secretField]); + return normalizeOptionalString(provider[secretField]); } if (details.sourceKind === "hermes-auth-json") { const auth = await readAuthJson(source); @@ -276,7 +277,7 @@ async function readSecretCandidateValue( const entry = entries.find( (candidate) => isRecord(candidate) && candidate.id === details.sourceCredentialId, ); - return isRecord(entry) ? readString(entry.access_token) : undefined; + return isRecord(entry) ? normalizeOptionalString(entry.access_token) : undefined; } if (!details.envVar) { return undefined; diff --git a/extensions/minimax/speech-provider.ts b/extensions/minimax/speech-provider.ts index 91b1d2d59d19..fc9ad1d7e905 100644 --- a/extensions/minimax/speech-provider.ts +++ b/extensions/minimax/speech-provider.ts @@ -13,12 +13,11 @@ import type { SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; import { - asObject, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; -import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_MINIMAX_TTS_BASE_URL, MINIMAX_TTS_MODELS, @@ -53,8 +52,8 @@ type MinimaxTtsProviderOverrides = { }; function resolveConfiguredPortalTtsBaseUrl(cfg: OpenClawConfig | undefined): string | undefined { - const providers = asObject(asObject(cfg?.models)?.providers); - const portalProvider = asObject(providers?.[MINIMAX_PORTAL_PROVIDER_ID]); + const providers = asOptionalRecord(asOptionalRecord(cfg?.models)?.providers); + const portalProvider = asOptionalRecord(providers?.[MINIMAX_PORTAL_PROVIDER_ID]); const portalBaseUrl = trimToUndefined(portalProvider?.baseUrl); return portalBaseUrl ? normalizeMinimaxTtsBaseUrl(portalBaseUrl) : undefined; } @@ -97,8 +96,8 @@ function normalizeMinimaxProviderConfig( rawConfig: Record, cfg?: OpenClawConfig, ): MinimaxTtsProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.minimax) ?? asObject(rawConfig.minimax); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.minimax) ?? asOptionalRecord(rawConfig.minimax); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 731b0e13c4dd..a026efa87813 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -20,7 +20,10 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup-runtime"; import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + readStringValue, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount } from "./accounts.js"; import type { CoreConfig } from "./types.js"; @@ -36,8 +39,9 @@ type NextcloudSetupInput = ChannelSetupInput & { password?: string; }; -function readOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.length > 0 ? value : undefined; +function readNonEmptyUntrimmedString(value: unknown): string | undefined { + const text = readStringValue(value); + return text ? text : undefined; } export function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { @@ -147,12 +151,12 @@ const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { const setupInput = input as NextcloudSetupInput; return { ...setupInput, - baseUrl: setupInput.baseUrl ?? readOptionalString(setupInput.url), + baseUrl: setupInput.baseUrl ?? readNonEmptyUntrimmedString(setupInput.url), secret: setupInput.secret ?? - readOptionalString(setupInput.token) ?? - readOptionalString(setupInput.password), - secretFile: setupInput.secretFile ?? readOptionalString(setupInput.tokenFile), + readNonEmptyUntrimmedString(setupInput.token) ?? + readNonEmptyUntrimmedString(setupInput.password), + secretFile: setupInput.secretFile ?? readNonEmptyUntrimmedString(setupInput.tokenFile), }; }, applyAccountName: ({ cfg, accountId, name }) => diff --git a/extensions/ollama/src/node-inference.ts b/extensions/ollama/src/node-inference.ts index 1f6cc9a41cec..4c0e96b97ce9 100644 --- a/extensions/ollama/src/node-inference.ts +++ b/extensions/ollama/src/node-inference.ts @@ -18,6 +18,7 @@ import { readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asNullableRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { DEFAULT_INFERENCE_TIMEOUT_MS, @@ -82,17 +83,11 @@ type NodeSummary = Awaited< ReturnType >["nodes"][number]; -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function readNodeCommandParams(paramsJSON?: string | null): Record { if (!paramsJSON) { return {}; } - const parsed = asRecord(JSON.parse(paramsJSON)); + const parsed = asNullableRecord(JSON.parse(paramsJSON)); if (!parsed) { throw new Error("node inference params must be a JSON object"); } @@ -144,7 +139,7 @@ async function requestOllamaJson(params: { const body = (await readResponseTextLimited(response, MAX_ERROR_BODY_BYTES)).trim(); let detail = body; try { - const parsed = asRecord(JSON.parse(body)); + const parsed = asNullableRecord(JSON.parse(body)); detail = typeof parsed?.error === "string" ? parsed.error : body; } catch { // Keep the bounded response text when Ollama returns a non-JSON error. @@ -401,10 +396,10 @@ function findNode(nodes: NodeSummary[], query: string): NodeSummary { } function parseInvokePayload(raw: unknown): Record { - const result = asRecord(raw); - let payload = asRecord(result?.payload); + const result = asNullableRecord(raw); + let payload = asNullableRecord(result?.payload); if (!payload && typeof result?.payloadJSON === "string") { - payload = asRecord(JSON.parse(result.payloadJSON)); + payload = asNullableRecord(JSON.parse(result.payloadJSON)); } if (!payload) { throw new Error("node returned an invalid Ollama inference payload"); @@ -437,7 +432,7 @@ export function createOllamaNodeInferenceTool(api: OpenClawPluginApi): AnyAgentT ...ollamaNodeInferenceToolDefinition, execute: async (_toolCallId, args, signal) => { throwIfOllamaRequestAborted(signal); - const params = asRecord(args) ?? {}; + const params = asNullableRecord(args) ?? {}; const action = readStringParam(params, "action", { required: true }); const nodeQuery = readStringParam(params, "node"); const listed = await api.runtime.nodes.list({ connected: true }); diff --git a/extensions/onepassword/src/config.ts b/extensions/onepassword/src/config.ts index 1f1faf496fed..f0bbfbff6240 100644 --- a/extensions/onepassword/src/config.ts +++ b/extensions/onepassword/src/config.ts @@ -1,5 +1,5 @@ import path from "node:path"; -import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; export const MAX_REGISTERED_ITEMS = 32; @@ -33,7 +33,10 @@ function requiredString(record: Record, key: string): string { return value.trim(); } -function optionalString(record: Record, key: string): string | undefined { +function readOptionalOnePasswordString( + record: Record, + key: string, +): string | undefined { const value = record[key]; if (value === undefined) { return undefined; @@ -54,7 +57,7 @@ function readPolicy(value: unknown, label: string, fallback: OnePasswordPolicy): throw new Error(`1Password config ${label} must be auto, approve, or deny`); } -function readNumber( +function readOnePasswordNumber( record: Record, key: string, fallback: number, @@ -108,17 +111,17 @@ export function parseOnePasswordConfig(value: unknown): OnePasswordConfig | unde if (item.startsWith("-")) { throw new Error(`1Password config item ${slug} item must not start with a hyphen`); } - const itemVault = optionalString(rawItem, "vault") ?? vault; + const itemVault = readOptionalOnePasswordString(rawItem, "vault") ?? vault; if (itemVault.startsWith("-")) { throw new Error(`1Password config item ${slug} vault must not start with a hyphen`); } - const description = optionalString(rawItem, "description"); + const description = readOptionalOnePasswordString(rawItem, "description"); if (description && description.length > MAX_DESCRIPTION_LENGTH) { throw new Error( `1Password config item ${slug} description must be at most ${MAX_DESCRIPTION_LENGTH} characters`, ); } - const field = optionalString(rawItem, "field") ?? "credential"; + const field = readOptionalOnePasswordString(rawItem, "field") ?? "credential"; // op treats commas in --fields as multiple selectors. Reject them so one // registry entry can never load more than its single configured field. if (field.includes(",")) { @@ -133,7 +136,7 @@ export function parseOnePasswordConfig(value: unknown): OnePasswordConfig | unde }; } - const opBin = optionalString(value, "opBin"); + const opBin = readOptionalOnePasswordString(value, "opBin"); if (opBin && !path.isAbsolute(opBin)) { throw new Error("1Password config opBin must be an absolute path"); } @@ -142,15 +145,15 @@ export function parseOnePasswordConfig(value: unknown): OnePasswordConfig | unde vault, ...(opBin ? { opBin } : {}), defaultPolicy, - cacheTtlSeconds: readNumber(value, "cacheTtlSeconds", 300, { + cacheTtlSeconds: readOnePasswordNumber(value, "cacheTtlSeconds", 300, { integer: true, allowZero: true, }), - grantTtlHours: readNumber(value, "grantTtlHours", 720, { + grantTtlHours: readOnePasswordNumber(value, "grantTtlHours", 720, { integer: false, allowZero: false, }), - opTimeoutMs: readNumber(value, "opTimeoutMs", 15_000, { + opTimeoutMs: readOnePasswordNumber(value, "opTimeoutMs", 15_000, { integer: true, allowZero: false, }), diff --git a/extensions/openai/realtime-provider-shared.ts b/extensions/openai/realtime-provider-shared.ts index d464bf102ba8..75751cc1e7cd 100644 --- a/extensions/openai/realtime-provider-shared.ts +++ b/extensions/openai/realtime-provider-shared.ts @@ -8,8 +8,7 @@ import { import { captureWsEvent } from "openclaw/plugin-sdk/proxy-capture"; import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { - asFiniteNumber, - asOptionalRecord as asObjectRecord, + asOptionalRecord, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -23,14 +22,11 @@ const OPENAI_REALTIME_SSRF_POLICY = { // with the maintained realtime Talk live smoke. const OPENAI_REALTIME_CLIENT_SECRET_REQUEST_TIMEOUT_MS = 30_000; -export const trimToUndefined = normalizeOptionalString; -export { asFiniteNumber, asObjectRecord }; - export function readRealtimeErrorDetail(error: unknown): string { if (typeof error === "string" && error) { return error; } - const message = asObjectRecord(error)?.message; + const message = asOptionalRecord(error)?.message; if (typeof message === "string" && message) { return message; } @@ -40,9 +36,11 @@ export function readRealtimeErrorDetail(error: unknown): string { export function resolveOpenAIProviderConfigRecord( config: Record, ): Record | undefined { - const providers = asObjectRecord(config.providers); + const providers = asOptionalRecord(config.providers); return ( - asObjectRecord(providers?.openai) ?? asObjectRecord(config.openai) ?? asObjectRecord(config) + asOptionalRecord(providers?.openai) ?? + asOptionalRecord(config.openai) ?? + asOptionalRecord(config) ); } @@ -85,14 +83,6 @@ type OpenAIRealtimeSecretRequest = { missingValueMessage: string; }; -function readStringField(value: unknown, key: string): string | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - const raw = (value as Record)[key]; - return typeof raw === "string" && raw.trim() ? raw.trim() : undefined; -} - async function createOpenAIRealtimeSecret( params: OpenAIRealtimeSecretRequest, ): Promise { @@ -140,7 +130,9 @@ async function createOpenAIRealtimeSecret( payload && typeof payload === "object" ? (payload as Record).client_secret : undefined; - const clientSecret = readStringField(payload, "value") ?? readStringField(nestedSecret, "value"); + const clientSecret = + normalizeOptionalString(asOptionalRecord(payload)?.value) ?? + normalizeOptionalString(asOptionalRecord(nestedSecret)?.value); if (!clientSecret) { throw new Error(params.missingValueMessage); } diff --git a/extensions/openai/realtime-quicksilver-delegation-controller.ts b/extensions/openai/realtime-quicksilver-delegation-controller.ts index 26e9d0d07895..51138b913cc9 100644 --- a/extensions/openai/realtime-quicksilver-delegation-controller.ts +++ b/extensions/openai/realtime-quicksilver-delegation-controller.ts @@ -1,3 +1,4 @@ +import { formatErrorMessage, toErrorObject } from "openclaw/plugin-sdk/error-runtime"; import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; import type { RealtimeVoiceAgentConsultRunner } from "openclaw/plugin-sdk/realtime-voice"; import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; @@ -36,12 +37,8 @@ type OpenAIQuicksilverDelegationControllerOptions = { signal: AbortSignal; }; -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - function shortFailureReason(error: unknown): string { - return toError(error).message.replaceAll(/\s+/g, " ").trim().slice(0, 180) || "unknown error"; + return formatErrorMessage(error).replaceAll(/\s+/g, " ").trim().slice(0, 180) || "unknown error"; } function readWireEventType(payload: string): string | undefined { @@ -185,7 +182,9 @@ export class OpenAIQuicksilverDelegationController { this.activeDelegationId = delegation.id; const signal = AbortSignal.any([this.options.signal, controller.signal]); void this.runDelegation(delegation, signal) - .catch((error: unknown) => this.fail(toError(error))) + .catch((error: unknown) => + this.fail(toErrorObject(error, "OpenAI GPT-Live delegation failed")), + ) .finally(() => { if (this.consultController !== controller) { return; diff --git a/extensions/openai/realtime-quicksilver-delegation.test.ts b/extensions/openai/realtime-quicksilver-delegation.test.ts index e1590672b2f8..cdac73d975e6 100644 --- a/extensions/openai/realtime-quicksilver-delegation.test.ts +++ b/extensions/openai/realtime-quicksilver-delegation.test.ts @@ -308,6 +308,29 @@ describe("GPT-Live sideband protocol", () => { expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("workspace unavailable")); }); + it("handles structured delegated failures with a non-string message", async () => { + const structuredFailure = { code: "UNAVAILABLE", message: 503 } as unknown as Error; + const runAgentConsult = vi.fn(() => Promise.reject(structuredFailure)); + const { controller, logger, socket } = createDelegationHarness({ runAgentConsult }); + + delegate(controller, "delegation-structured-failure", "do work"); + + await vi.waitFor(() => expect(socket.sent.length).toBeGreaterThan(0)); + expect(parseSent(socket)).toContainEqual( + expect.objectContaining({ + delegation_item_id: "delegation-structured-failure", + channel: "speakable", + content: [ + { + type: "input_text", + text: "The agent task failed. Tell the user it did not complete and offer to try again.", + }, + ], + }), + ); + expect(logger.warn).toHaveBeenCalled(); + }); + it("surfaces fatal sideband errors to the lifecycle owner", () => { const { controller, logger, onFatalError } = createDelegationHarness(); controller.handleEvent({ kind: "error", message: "token expired", fatalAuth: true }); diff --git a/extensions/openai/realtime-quicksilver-gateway-bridge.ts b/extensions/openai/realtime-quicksilver-gateway-bridge.ts index 9fd41004fdd4..e6740e0acfbc 100644 --- a/extensions/openai/realtime-quicksilver-gateway-bridge.ts +++ b/extensions/openai/realtime-quicksilver-gateway-bridge.ts @@ -1,5 +1,6 @@ // Gateway-owned GPT-Live WebRTC bridge: werift media peer plus OpenAI sideband control. import { randomUUID } from "node:crypto"; +import { toErrorObject } from "openclaw/plugin-sdk/error-runtime"; import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; import type { RealtimeVoiceBridge, @@ -33,10 +34,6 @@ const QUICKSILVER_SESSION_TTL_MS = 30 * 60_000; const QUICKSILVER_CONNECT_TIMEOUT_MS = 30_000; const WEBSOCKET_OPEN = 1; -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - function isAbortLikeError(error: unknown): boolean { if (!error || typeof error !== "object") { return false; @@ -100,7 +97,7 @@ function waitForConnectStep(promise: Promise, signal: AbortSignal): Promis }, (error: unknown) => { signal.removeEventListener("abort", onAbort); - reject(toError(error)); + reject(toErrorObject(error, "OpenAI GPT-Live gateway relay failed")); }, ); }); @@ -302,7 +299,7 @@ export class OpenAIQuicksilverGatewayBridge implements RealtimeVoiceBridge { } } catch (error) { this.releaseResources(); - throw toError(error); + throw toErrorObject(error, "OpenAI GPT-Live gateway relay failed"); } } diff --git a/extensions/openai/realtime-quicksilver-peer.runtime.ts b/extensions/openai/realtime-quicksilver-peer.runtime.ts index 5ae8d6119d24..d9900e883206 100644 --- a/extensions/openai/realtime-quicksilver-peer.runtime.ts +++ b/extensions/openai/realtime-quicksilver-peer.runtime.ts @@ -1,5 +1,6 @@ // Lazy GPT-Live media runtime: werift peer plus WASM Opus framing and PCM conversion. import { randomInt } from "node:crypto"; +import { toErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { resamplePcm } from "openclaw/plugin-sdk/realtime-voice"; import { OpenAIQuicksilverPendingAudio, @@ -49,10 +50,6 @@ export type OpenAIQuicksilverAudioPeerContract = { close(): void; }; -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - function pcmBufferToInt16(pcm: Buffer): Int16Array { const samples = new Int16Array(Math.floor(pcm.length / 2)); for (let index = 0; index < samples.length; index += 1) { @@ -305,7 +302,7 @@ export class OpenAIQuicksilverAudioPeer implements OpenAIQuicksilverAudioPeerCon this.flushInboundReorderWindow(state); this.scheduleInboundFlush(state); } catch (error) { - this.state.callbacks.onError(toError(error)); + this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed")); } } @@ -376,7 +373,7 @@ export class OpenAIQuicksilverAudioPeer implements OpenAIQuicksilverAudioPeerCon try { this.flushInboundReorderWindow(state, true); } catch (error) { - this.state.callbacks.onError(toError(error)); + this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed")); } }, INBOUND_REORDER_DEPTH * OPUS_FRAME_DURATION_MS); state.flushTimer.unref?.(); @@ -438,10 +435,10 @@ export class OpenAIQuicksilverAudioPeer implements OpenAIQuicksilverAudioPeerCon // werift queues encrypted UDP synchronously before sendRtp yields // (rtpSender.js:538; transport/dtls.js:455), preserving per-tick order. void this.state.transceiver.sender.sendRtp(rtp).catch((error: unknown) => { - this.state.callbacks.onError(toError(error)); + this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed")); }); } catch (error) { - this.state.callbacks.onError(toError(error)); + this.state.callbacks.onError(toErrorObject(error, "OpenAI GPT-Live WebRTC media failed")); } } diff --git a/extensions/openai/realtime-transcription-provider.ts b/extensions/openai/realtime-transcription-provider.ts index d84ffd3a17cd..6599515c3009 100644 --- a/extensions/openai/realtime-transcription-provider.ts +++ b/extensions/openai/realtime-transcription-provider.ts @@ -14,12 +14,11 @@ import { type RealtimeTranscriptionWebSocketTransport, } from "openclaw/plugin-sdk/realtime-transcription"; import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; +import { asFiniteNumber, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { - asFiniteNumber, createOpenAIRealtimeTranscriptionClientSecret, readRealtimeErrorDetail, resolveOpenAIProviderConfigRecord, - trimToUndefined, } from "./realtime-provider-shared.js"; type OpenAIRealtimeTranscriptionProviderConfig = { @@ -125,9 +124,9 @@ function normalizeProviderConfig( value: raw?.openaiApiKey, path: "plugins.entries.voice-call.config.streaming.openaiApiKey", }), - language: trimToUndefined(raw?.language), - model: trimToUndefined(raw?.model) ?? trimToUndefined(raw?.sttModel), - prompt: trimToUndefined(raw?.prompt), + language: normalizeOptionalString(raw?.language), + model: normalizeOptionalString(raw?.model) ?? normalizeOptionalString(raw?.sttModel), + prompt: normalizeOptionalString(raw?.prompt), silenceDurationMs: normalizeNonNegativeInteger(raw?.silenceDurationMs), vadThreshold: normalizeVadThreshold(raw?.vadThreshold), }; diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 7cf99c47b010..7439acbf17e7 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -6,6 +6,7 @@ import type { RealtimeVoiceBridgeEvent, RealtimeVoiceTool, } from "openclaw/plugin-sdk/realtime-voice"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; @@ -311,10 +312,6 @@ function createJsonResponse(body: unknown, init?: { status?: number }): Response }); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function requireRecord(value: unknown, label: string): Record { expect(isRecord(value), `${label} must be an object`).toBe(true); return value as Record; diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index d3bb3fc78946..80e958ec4270 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -38,15 +38,17 @@ import { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/secret-input"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; -import WebSocket from "ws"; import { asFiniteNumber, + isRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import WebSocket from "ws"; +import { captureOpenAIRealtimeWsClose, createOpenAIRealtimeClientSecret, readRealtimeErrorDetail, resolveOpenAIProviderConfigRecord, - trimToUndefined, } from "./realtime-provider-shared.js"; import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js"; import { OpenAIQuicksilverGatewayBridge } from "./realtime-quicksilver-gateway-bridge.js"; @@ -272,7 +274,7 @@ function normalizeProviderConfig( value: raw?.apiKey, path: "plugins.entries.voice-call.config.realtime.providers.openai.apiKey", }), - model: trimToUndefined(raw?.model), + model: normalizeOptionalString(raw?.model), voice: normalizeOpenAIRealtimeVoice(raw?.speakerVoice ?? raw?.voice), temperature: asFiniteNumber(raw?.temperature), vadThreshold: asUnitInterval(raw?.vadThreshold), @@ -283,10 +285,10 @@ function normalizeProviderConfig( ? raw.interruptResponseOnInputAudio : undefined, minBargeInAudioEndMs: asNonNegativeInteger(raw?.minBargeInAudioEndMs), - reasoningEffort: trimToUndefined(raw?.reasoningEffort), - azureEndpoint: trimToUndefined(raw?.azureEndpoint), - azureDeployment: trimToUndefined(raw?.azureDeployment), - azureApiVersion: trimToUndefined(raw?.azureApiVersion), + reasoningEffort: normalizeOptionalString(raw?.reasoningEffort), + azureEndpoint: normalizeOptionalString(raw?.azureEndpoint), + azureDeployment: normalizeOptionalString(raw?.azureDeployment), + azureApiVersion: normalizeOptionalString(raw?.azureApiVersion), }; } @@ -2113,7 +2115,7 @@ function buildOpenAIRealtimeBrowserSessionConfig( session.tools = tools; session.tool_choice = "auto"; } - const reasoningEffort = trimToUndefined(req.reasoningEffort) ?? config.reasoningEffort; + const reasoningEffort = normalizeOptionalString(req.reasoningEffort) ?? config.reasoningEffort; if (reasoningEffort) { session.reasoning = { effort: reasoningEffort }; } @@ -2150,7 +2152,7 @@ async function createOpenAIRealtimeBrowserSession( model, noiseReduction: { type: "near_field" }, prefixPaddingMs: req.prefixPaddingMs ?? config.prefixPaddingMs, - reasoningEffort: trimToUndefined(req.reasoningEffort) ?? config.reasoningEffort, + reasoningEffort: normalizeOptionalString(req.reasoningEffort) ?? config.reasoningEffort, silenceDurationMs: req.silenceDurationMs ?? config.silenceDurationMs, tools: normalizeOpenAIRealtimeTools(req.tools), vadThreshold: req.vadThreshold ?? config.vadThreshold, @@ -2204,7 +2206,7 @@ async function createOpenAIRealtimeBrowserSession( if (!quicksilverBroker) { throw new Error("OpenAI GPT-Live browser session broker is unavailable"); } - const configuredVoice = trimToUndefined(rawConfig?.speakerVoice ?? rawConfig?.voice); + const configuredVoice = normalizeOptionalString(rawConfig?.speakerVoice ?? rawConfig?.voice); const quicksilverRequest = { ...req, model, diff --git a/extensions/openai/speech-provider.ts b/extensions/openai/speech-provider.ts index 58a24498b8a1..c2b041bc28fd 100644 --- a/extensions/openai/speech-provider.ts +++ b/extensions/openai/speech-provider.ts @@ -10,15 +10,13 @@ import type { } from "openclaw/plugin-sdk/speech-core"; import { parseSpeechDirectiveNumberOverride } from "openclaw/plugin-sdk/speech-core"; import { + asFiniteNumber, + asOptionalRecord, normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, + normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { - asFiniteNumber, - asObjectRecord, - resolveOpenAIProviderConfigRecord, - trimToUndefined, -} from "./realtime-provider-shared.js"; +import { resolveOpenAIProviderConfigRecord } from "./realtime-provider-shared.js"; import { DEFAULT_OPENAI_BASE_URL, isValidOpenAIModel, @@ -51,7 +49,9 @@ type OpenAITtsProviderOverrides = { }; function resolveOpenAISpeechApiKey(config: OpenAITtsProviderConfig): string | undefined { - return trimToUndefined(config.apiKey) ?? trimToUndefined(process.env.OPENAI_API_KEY); + return ( + normalizeOptionalString(config.apiKey) ?? normalizeOptionalString(process.env.OPENAI_API_KEY) + ); } function normalizeOpenAISpeechResponseFormat( @@ -106,7 +106,7 @@ function responseFormatToFileExtension( } function readExtraBody(value: unknown): Record | undefined { - const body = asObjectRecord(value); + const body = asOptionalRecord(value); if (!body || Object.keys(body).length === 0) { return undefined; } @@ -130,8 +130,8 @@ function normalizeOpenAIProviderConfig( const raw = resolveOpenAIProviderConfigRecord(rawConfig); const extraBody = readExtraBody(raw?.extraBody) ?? readExtraBody(raw?.extra_body); const baseUrl = normalizeOpenAITtsBaseUrl( - trimToUndefined(raw?.baseUrl) ?? - trimToUndefined(process.env.OPENAI_TTS_BASE_URL) ?? + normalizeOptionalString(raw?.baseUrl) ?? + normalizeOptionalString(process.env.OPENAI_TTS_BASE_URL) ?? DEFAULT_OPENAI_BASE_URL, ); return { @@ -140,10 +140,10 @@ function normalizeOpenAIProviderConfig( path: "tts.providers.openai.apiKey", }), baseUrl, - model: trimToUndefined(raw?.model) ?? "gpt-4o-mini-tts", - voice: trimToUndefined(raw?.voice) ?? "coral", + model: normalizeOptionalString(raw?.model) ?? "gpt-4o-mini-tts", + voice: normalizeOptionalString(raw?.voice) ?? "coral", speed: normalizeOpenAISpeechSpeed(raw?.speed, baseUrl), - instructions: trimToUndefined(raw?.instructions), + instructions: normalizeOptionalString(raw?.instructions), responseFormat: normalizeOpenAISpeechResponseFormat(raw?.responseFormat), extraBody, }; @@ -152,16 +152,16 @@ function normalizeOpenAIProviderConfig( function readOpenAIProviderConfig(config: SpeechProviderConfig): OpenAITtsProviderConfig { const normalized = normalizeOpenAIProviderConfig({}); return { - apiKey: trimToUndefined(config.apiKey) ?? normalized.apiKey, - baseUrl: trimToUndefined(config.baseUrl) ?? normalized.baseUrl, - model: trimToUndefined(config.model) ?? normalized.model, - voice: trimToUndefined(config.voice) ?? normalized.voice, + apiKey: normalizeOptionalString(config.apiKey) ?? normalized.apiKey, + baseUrl: normalizeOptionalString(config.baseUrl) ?? normalized.baseUrl, + model: normalizeOptionalString(config.model) ?? normalized.model, + voice: normalizeOptionalString(config.voice) ?? normalized.voice, speed: normalizeOpenAISpeechSpeed( config.speed, - trimToUndefined(config.baseUrl) ?? normalized.baseUrl, + normalizeOptionalString(config.baseUrl) ?? normalized.baseUrl, ) ?? normalized.speed, - instructions: trimToUndefined(config.instructions) ?? normalized.instructions, + instructions: normalizeOptionalString(config.instructions) ?? normalized.instructions, responseFormat: normalizeOpenAISpeechResponseFormat(config.responseFormat) ?? normalized.responseFormat, extraBody: readExtraBody(config.extraBody) ?? readExtraBody(config.extra_body), @@ -176,8 +176,8 @@ function readOpenAIOverrides( return {}; } return { - model: trimToUndefined(overrides.model), - voice: trimToUndefined(overrides.voice), + model: normalizeOptionalString(overrides.model), + voice: normalizeOptionalString(overrides.voice), speed: normalizeOpenAISpeechSpeed(overrides.speed, baseUrl), }; } @@ -194,7 +194,7 @@ function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): { overrides?: SpeechProviderOverrides; warnings?: string[]; } { - const baseUrl = trimToUndefined(asObjectRecord(ctx.providerConfig)?.baseUrl); + const baseUrl = normalizeOptionalString(asOptionalRecord(ctx.providerConfig)?.baseUrl); switch (ctx.key) { case "voice": case "openai_voice": @@ -248,7 +248,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin { resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => { const base = normalizeOpenAIProviderConfig(baseTtsConfig); const responseFormat = normalizeOpenAISpeechResponseFormat(talkProviderConfig.responseFormat); - const baseUrl = trimToUndefined(talkProviderConfig.baseUrl) ?? base.baseUrl; + const baseUrl = normalizeOptionalString(talkProviderConfig.baseUrl) ?? base.baseUrl; const speed = normalizeOpenAISpeechSpeed(talkProviderConfig.speed, baseUrl); return { ...base, @@ -260,27 +260,27 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin { path: "talk.providers.openai.apiKey", }), }), - ...(trimToUndefined(talkProviderConfig.baseUrl) == null ? {} : { baseUrl }), - ...(trimToUndefined(talkProviderConfig.modelId) == null + ...(normalizeOptionalString(talkProviderConfig.baseUrl) == null ? {} : { baseUrl }), + ...(normalizeOptionalString(talkProviderConfig.modelId) == null ? {} - : { model: trimToUndefined(talkProviderConfig.modelId) }), - ...(trimToUndefined(talkProviderConfig.voiceId) == null + : { model: normalizeOptionalString(talkProviderConfig.modelId) }), + ...(normalizeOptionalString(talkProviderConfig.voiceId) == null ? {} - : { voice: trimToUndefined(talkProviderConfig.voiceId) }), + : { voice: normalizeOptionalString(talkProviderConfig.voiceId) }), ...(speed == null ? {} : { speed }), - ...(trimToUndefined(talkProviderConfig.instructions) == null + ...(normalizeOptionalString(talkProviderConfig.instructions) == null ? {} - : { instructions: trimToUndefined(talkProviderConfig.instructions) }), + : { instructions: normalizeOptionalString(talkProviderConfig.instructions) }), ...(responseFormat == null ? {} : { responseFormat }), }; }, resolveTalkOverrides: ({ params }) => ({ - ...(trimToUndefined(params.voiceId) == null + ...(normalizeOptionalString(params.voiceId) == null ? {} - : { voice: trimToUndefined(params.voiceId) }), - ...(trimToUndefined(params.modelId) == null + : { voice: normalizeOptionalString(params.voiceId) }), + ...(normalizeOptionalString(params.modelId) == null ? {} - : { model: trimToUndefined(params.modelId) }), + : { model: normalizeOptionalString(params.modelId) }), ...(asFiniteNumber(params.speed) == null ? {} : { speed: asFiniteNumber(params.speed) }), }), listVoices: async () => OPENAI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })), diff --git a/extensions/openrouter/oauth.ts b/extensions/openrouter/oauth.ts index efa3b379d947..206435d7f2c0 100644 --- a/extensions/openrouter/oauth.ts +++ b/extensions/openrouter/oauth.ts @@ -12,7 +12,7 @@ import { readProviderJsonResponse, readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "openrouter"; @@ -48,10 +48,6 @@ type OpenRouterOAuthLoginOptions = { waitForCallback?: typeof waitForOpenRouterOAuthCallback; }; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function extractOpenRouterError(value: unknown): string | undefined { if (typeof value === "string") { return value.trim() || undefined; @@ -59,7 +55,8 @@ function extractOpenRouterError(value: unknown): string | undefined { if (!isRecord(value)) { return undefined; } - const direct = readString(value.message) ?? readString(value.error_description); + const direct = + normalizeOptionalString(value.message) ?? normalizeOptionalString(value.error_description); if (direct) { return direct; } @@ -68,7 +65,7 @@ function extractOpenRouterError(value: unknown): string | undefined { return error.trim() || undefined; } if (isRecord(error)) { - return readString(error.message) ?? readString(error.code); + return normalizeOptionalString(error.message) ?? normalizeOptionalString(error.code); } return undefined; } @@ -95,11 +92,11 @@ function parseOpenRouterKeyResponse(value: unknown): OpenRouterOAuthKeyResult { if (!isRecord(value)) { throw new Error("OpenRouter OAuth key exchange returned an unexpected response."); } - const key = readString(value.key); + const key = normalizeOptionalString(value.key); if (!key) { throw new Error("OpenRouter OAuth key exchange returned no API key."); } - const userId = readString(value.user_id) ?? readString(value.userId); + const userId = normalizeOptionalString(value.user_id) ?? normalizeOptionalString(value.userId); return { key, ...(userId ? { userId } : {}), @@ -143,15 +140,18 @@ function parseOpenRouterOAuthCallbackInput( } const parseParams = (params: URLSearchParams): OpenRouterOAuthCallbackResult => { - const state = requireOpenRouterOAuthState(readString(params.get("state")), expectedState); - const error = readString(params.get("error")); + const state = requireOpenRouterOAuthState( + normalizeOptionalString(params.get("state")), + expectedState, + ); + const error = normalizeOptionalString(params.get("error")); if (error) { - const description = readString(params.get("error_description")); + const description = normalizeOptionalString(params.get("error_description")); throw new Error( `OpenRouter OAuth error: ${description ? `${error}: ${description}` : error}`, ); } - const code = readString(params.get("code")); + const code = normalizeOptionalString(params.get("code")); if (!code) { throw new Error("Missing 'code' parameter in redirect URL."); } @@ -238,7 +238,7 @@ async function waitForOpenRouterOAuthCallback(params: { return; } - const state = readString(requestUrl.searchParams.get("state")); + const state = normalizeOptionalString(requestUrl.searchParams.get("state")); try { requireOpenRouterOAuthState(state, params.expectedState); } catch (err) { @@ -249,9 +249,11 @@ async function waitForOpenRouterOAuthCallback(params: { return; } - const error = readString(requestUrl.searchParams.get("error")); + const error = normalizeOptionalString(requestUrl.searchParams.get("error")); if (error) { - const description = readString(requestUrl.searchParams.get("error_description")); + const description = normalizeOptionalString( + requestUrl.searchParams.get("error_description"), + ); const detail = description ? `${error}: ${description}` : error; res.statusCode = 400; res.setHeader("Content-Type", "text/plain"); @@ -260,7 +262,7 @@ async function waitForOpenRouterOAuthCallback(params: { return; } - const code = readString(requestUrl.searchParams.get("code")); + const code = normalizeOptionalString(requestUrl.searchParams.get("code")); if (!code) { res.statusCode = 400; res.setHeader("Content-Type", "text/plain"); diff --git a/extensions/openrouter/speech-provider.ts b/extensions/openrouter/speech-provider.ts index 4faa24d4e546..16df4db02436 100644 --- a/extensions/openrouter/speech-provider.ts +++ b/extensions/openrouter/speech-provider.ts @@ -1,9 +1,9 @@ // Openrouter provider module implements model/runtime integration. import { - asObject, createOpenAiCompatibleSpeechProvider, type SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { OPENROUTER_BASE_URL } from "./provider-catalog.js"; const DEFAULT_OPENROUTER_TTS_MODEL = "hexgrad/kokoro-82m"; @@ -41,7 +41,7 @@ export function buildOpenRouterSpeechProvider(): SpeechProviderPlugin { }, apiErrorLabel: "OpenRouter TTS API error", missingApiKeyError: "OpenRouter API key missing", - readExtraConfig: (raw) => ({ provider: asObject(raw?.provider) }), + readExtraConfig: (raw) => ({ provider: asOptionalRecord(raw?.provider) }), extraJsonBodyFields: [{ configKey: "provider" }], }); } diff --git a/extensions/openrouter/stream.ts b/extensions/openrouter/stream.ts index 900b2bbe192a..dae7d19407ff 100644 --- a/extensions/openrouter/stream.ts +++ b/extensions/openrouter/stream.ts @@ -8,6 +8,7 @@ import { normalizeOpenAICompatibleReasoningReplay, } from "openclaw/plugin-sdk/provider-stream-shared"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import { isOpenRouterDeepSeekV4ModelId, normalizeOpenRouterModelFamilyId } from "./models.js"; import { isOpenRouterProxyReasoningUnsupportedModel, @@ -18,13 +19,13 @@ import { const log = createSubsystemLogger("openrouter-stream"); const openRouterThinkingStreamHooks = buildProviderStreamFamilyHooks("openrouter-thinking"); -function readString(value: unknown): string | undefined { - return typeof value === "string" ? value.trim() : undefined; +function normalizeOpenRouterStringPreservingEmpty(value: unknown): string | undefined { + return readStringValue(value)?.trim(); } function isVerifiedOpenRouterRoute(model: Parameters[0]): boolean { - const provider = readString(model.provider)?.toLowerCase(); - const baseUrl = readString(model.baseUrl); + const provider = normalizeOpenRouterStringPreservingEmpty(model.provider)?.toLowerCase(); + const baseUrl = normalizeOpenRouterStringPreservingEmpty(model.baseUrl); if (baseUrl) { return normalizeOpenRouterBaseUrl(baseUrl) === OPENROUTER_BASE_URL; } @@ -32,7 +33,7 @@ function isVerifiedOpenRouterRoute(model: Parameters[0]): boolean { } function shouldPatchAnthropicOpenRouterPayload(model: Parameters[0]): boolean { - const api = readString(model.api); + const api = normalizeOpenRouterStringPreservingEmpty(model.api); return ( (api === undefined || api === "openai-completions") && normalizeOpenRouterModelFamilyId(model.id)?.startsWith("anthropic/") === true && @@ -41,7 +42,7 @@ function shouldPatchAnthropicOpenRouterPayload(model: Parameters[0]): } function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters[0]): boolean { - const api = readString(model.api); + const api = normalizeOpenRouterStringPreservingEmpty(model.api); return ( (api === undefined || api === "openai-completions") && isOpenRouterDeepSeekV4ModelId(model.id) && @@ -50,12 +51,12 @@ function shouldPatchDeepSeekV4OpenRouterPayload(model: Parameters[0]): } function shouldPatchOpenRouterRoutingPayload(model: Parameters[0]): boolean { - const api = readString(model.api); + const api = normalizeOpenRouterStringPreservingEmpty(model.api); return (api === undefined || api === "openai-completions") && isVerifiedOpenRouterRoute(model); } function mergeOpenRouterAuthHeaders(options: Parameters[2]): Parameters[2] { - const apiKey = readString(options?.apiKey); + const apiKey = normalizeOpenRouterStringPreservingEmpty(options?.apiKey); if (!apiKey) { return options; } diff --git a/extensions/policy/src/doctor/ingress-findings.ts b/extensions/policy/src/doctor/ingress-findings.ts index 6ae1a4d6d246..cf2835244bb4 100644 --- a/extensions/policy/src/doctor/ingress-findings.ts +++ b/extensions/policy/src/doctor/ingress-findings.ts @@ -6,7 +6,7 @@ import { CHECK_IDS, POLICY_CHECK_IDS } from "./check-ids.js"; import { normalizePolicyChannelId } from "./policy-runtime.js"; import { channelScopedPolicyTargets } from "./policy-scope.js"; import { hasValidScopedPolicy } from "./scoped-policy-shape.js"; -import { ocPathSegment, readPolicyBoolean, readString, readStringList } from "./utils.js"; +import { ocPathSegment, readPolicyBoolean, readPolicyPathString, readStringList } from "./utils.js"; export function ingressFindings( policy: unknown, @@ -104,7 +104,7 @@ function ingressDmScopeFindings( evidence: PolicyEvidence, evidenceFilter: (entry: PolicyIngressEvidence) => boolean, ): readonly HealthFinding[] { - const required = readString(ingressPolicy, ["session", "requireDmScope"]); + const required = readPolicyPathString(ingressPolicy, ["session", "requireDmScope"]); if (required === undefined) { return []; } diff --git a/extensions/policy/src/doctor/utils.ts b/extensions/policy/src/doctor/utils.ts index 6cff34bba5a8..11b644a7743d 100644 --- a/extensions/policy/src/doctor/utils.ts +++ b/extensions/policy/src/doctor/utils.ts @@ -33,7 +33,7 @@ export function readStringList( return readPolicyStringArray(policy, path, options) ?? []; } -export function readString(policy: unknown, path: readonly string[]): string | undefined { +export function readPolicyPathString(policy: unknown, path: readonly string[]): string | undefined { let current: unknown = policy; for (const part of path) { if (!isRecord(current)) { diff --git a/extensions/qa-lab/src/confidence-report.ts b/extensions/qa-lab/src/confidence-report.ts index 5fc580e76755..712011953c99 100644 --- a/extensions/qa-lab/src/confidence-report.ts +++ b/extensions/qa-lab/src/confidence-report.ts @@ -2,7 +2,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean as readBoolean, + asFiniteNumber as readNumber, + isRecord, + normalizeOptionalString as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { formatGatewayLogSentinelSummary, type GatewayLogSentinelFinding, @@ -141,22 +146,10 @@ const QA_CONFIDENCE_SELF_TEST_CANARY_IDS = [ "jsonl-replay-ordering-drift", ] as const; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - -function readNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - function readCount(value: unknown): number | undefined { return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; } -function readBoolean(value: unknown): boolean | undefined { - return typeof value === "boolean" ? value : undefined; -} - function readStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) { return undefined; diff --git a/extensions/qa-lab/src/evidence-gallery.ts b/extensions/qa-lab/src/evidence-gallery.ts index 633d9a93fa4e..21ff145e6d0f 100644 --- a/extensions/qa-lab/src/evidence-gallery.ts +++ b/extensions/qa-lab/src/evidence-gallery.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { StringDecoder } from "node:string_decoder"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime"; import pLimit from "p-limit"; import type { QaEvidenceArtifactView, @@ -564,10 +565,6 @@ async function buildArtifactView(params: { }; } -function readString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} - function readRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -610,7 +607,7 @@ function readMatrixDimensionIds(params: { if (typeof entry === "string") { return entry; } - return readString(readRecord(entry)?.id); + return readStringValue(readRecord(entry)?.id) ?? null; }), params, ); @@ -667,9 +664,9 @@ function readMatrixCells(params: { : []; const entriesByCell = buildUxMatrixEvidenceEntryIndex(params.summaryEntries); return rawCells.flatMap((cell): QaEvidenceMatrixCellView[] => { - const rawSurface = readString(cell.surface); - const rawStage = readString(cell.stage); - const rawStatus = readString(cell.status) ?? "proof-gap"; + const rawSurface = readStringValue(cell.surface) ?? null; + const rawStage = readStringValue(cell.stage) ?? null; + const rawStatus = readStringValue(cell.status) ?? "proof-gap"; if (!rawSurface || !rawStage) { return []; } @@ -683,7 +680,7 @@ function readMatrixCells(params: { repoRoot: params.repoRoot, }); const readRunnerString = (value: unknown) => { - const text = readString(value); + const text = readStringValue(value); return text ? sanitizeCellString(text) : null; }; return [ @@ -795,8 +792,8 @@ async function buildProducerContext(params: { const matrix = await readJsonIfExists(matrixPath, allowedRoots); const releaseLedger = await readJsonIfExists(releaseLedgerPath, allowedRoots); const run = readRecord(manifest?.run); - const runId = readString(run?.runId); - const runStatus = readString(run?.status); + const runId = readStringValue(run?.runId) ?? null; + const runStatus = readStringValue(run?.status) ?? null; const producerFiles = Object.fromEntries( await Promise.all( UX_MATRIX_PRODUCER_FILES.map(async (file) => [ diff --git a/extensions/qa-lab/src/jsonl-replay.ts b/extensions/qa-lab/src/jsonl-replay.ts index a0c9daa47bd4..5de5092c4d53 100644 --- a/extensions/qa-lab/src/jsonl-replay.ts +++ b/extensions/qa-lab/src/jsonl-replay.ts @@ -1,7 +1,7 @@ // Qa Lab plugin module implements jsonl replay behavior. import fs from "node:fs/promises"; import path from "node:path"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { runRuntimeParityScenario, type RuntimeId, @@ -52,23 +52,19 @@ type JsonlReplayMarkdownReport = { transcripts: JsonlReplayResult["transcripts"]; }; -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function readReplayMessage(record: Record): Record | undefined { if (isRecord(record.message)) { return record.message; } - return readString(record.role) ? record : undefined; + return normalizeOptionalString(record.role) ? record : undefined; } function readRole(message: Record) { - return readString(message.role)?.toLowerCase(); + return normalizeOptionalString(message.role)?.toLowerCase(); } function isTextLikeContentBlock(block: Record) { - const type = readString(block.type)?.toLowerCase(); + const type = normalizeOptionalString(block.type)?.toLowerCase(); return ( !type || type === "text" || @@ -97,7 +93,7 @@ function extractTextContent(content: unknown): string { if (!isRecord(block) || !isTextLikeContentBlock(block)) { continue; } - const text = readString(block.text) ?? readString(block.content); + const text = normalizeOptionalString(block.text) ?? normalizeOptionalString(block.content); if (text) { parts.push(text); } diff --git a/extensions/qa-lab/src/live-transports/matrix/substrate/config.test.ts b/extensions/qa-lab/src/live-transports/matrix/substrate/config.test.ts index 767a66234200..9399ee2c2869 100644 --- a/extensions/qa-lab/src/live-transports/matrix/substrate/config.test.ts +++ b/extensions/qa-lab/src/live-transports/matrix/substrate/config.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { buildMatrixQaConfig } from "./config.js"; import type { MatrixQaProvisionedTopology } from "./topology.js"; -function asRecord(value: unknown): Record { +function castRecord(value: unknown): Record { return value as Record; } @@ -385,19 +385,19 @@ describe("matrix qa config", () => { }, } as unknown as OpenClawConfig; const current = structuredClone(baseline) as OpenClawConfig & Record; - const currentRoot = asRecord(current); + const currentRoot = castRecord(current); currentRoot.unrelated = { currentOnly: true }; - asRecord(currentRoot.approvals).exec = { + castRecord(currentRoot.approvals).exec = { enabled: true, mode: "session", reviewer: "current", }; - asRecord(currentRoot.agents).defaults = { + castRecord(currentRoot.agents).defaults = { blockStreamingChunk: { maxChars: 8 }, adjacentDefault: "current", }; - const currentTools = asRecord(currentRoot.tools); - const currentMedia = asRecord(currentTools.media); + const currentTools = castRecord(currentRoot.tools); + const currentMedia = castRecord(currentTools.media); currentTools.profile = "coding"; currentMedia.models = [{ provider: "openai", model: "previous" }]; currentMedia.audio = { @@ -409,16 +409,16 @@ describe("matrix qa config", () => { rules: [{ action: "deny", match: { chatType: "group" } }], }, }; - asRecord(currentRoot.messages).groupChat = { + castRecord(currentRoot.messages).groupChat = { mentionPatterns: ["previous"], adjacentMessage: "current", }; - const currentMatrix = asRecord(asRecord(currentRoot.channels).matrix); + const currentMatrix = castRecord(castRecord(currentRoot.channels).matrix); currentMatrix.unknownRoot = "current"; - const currentAccounts = asRecord(currentMatrix.accounts); + const currentAccounts = castRecord(currentMatrix.accounts); currentAccounts.sibling = { enabled: false, homeserver: "https://sibling.invalid" }; currentAccounts["qa-driver-bot-source"] = { enabled: false, userId: "@stale:test" }; - const currentSut = asRecord(currentAccounts.sut); + const currentSut = castRecord(currentAccounts.sut); currentAccounts.sut = { ...currentSut, allowBots: "mentions", @@ -426,7 +426,7 @@ describe("matrix qa config", () => { deviceId: "CURRENT-DEVICE", lifecycleState: "current", dm: { - ...asRecord(currentSut.dm), + ...castRecord(currentSut.dm), allowFrom: ["@previous:test"], adjacentDm: "current", sessionScope: "per-user", @@ -440,7 +440,7 @@ describe("matrix qa config", () => { target: "both", }, groups: { - ...asRecord(currentSut.groups), + ...castRecord(currentSut.groups), "!main:matrix-qa.test": { allowBots: "mentions", adjacentGroup: "current", @@ -482,7 +482,7 @@ describe("matrix qa config", () => { topology, }); - expect(asRecord(next).unrelated).toEqual({ currentOnly: true }); + expect(castRecord(next).unrelated).toEqual({ currentOnly: true }); expect(next.approvals?.exec).toEqual({ enabled: false, mode: "session", @@ -513,7 +513,7 @@ describe("matrix qa config", () => { expect(next.channels?.matrix).toMatchObject({ unknownRoot: "current" }); expect(next.channels?.matrix?.accounts?.sibling).toEqual(currentAccounts.sibling); expect(next.channels?.matrix?.accounts?.["qa-driver-bot-source"]).toBeUndefined(); - const sut = asRecord(next.channels?.matrix?.accounts?.sut); + const sut = castRecord(next.channels?.matrix?.accounts?.sut); expect(sut).toMatchObject({ allowBots: false, autoJoin: "allowlist", @@ -548,7 +548,7 @@ describe("matrix qa config", () => { idleHours: 12, }, }); - const sutGroups = asRecord(sut.groups); + const sutGroups = castRecord(sut.groups); expect(sutGroups["!main:matrix-qa.test"]).toEqual({ adjacentGroup: "current", allowBots: false, diff --git a/extensions/qa-lab/src/runtime-tool-fixture.ts b/extensions/qa-lab/src/runtime-tool-fixture.ts index 0d0ea20478ba..b64cc7a9557a 100644 --- a/extensions/qa-lab/src/runtime-tool-fixture.ts +++ b/extensions/qa-lab/src/runtime-tool-fixture.ts @@ -4,7 +4,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { loadTranscriptEventsSync } from "openclaw/plugin-sdk/session-store-runtime"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asBoolean, + isRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { QaSuiteInfraError, QaSuiteScenarioSkipError } from "./errors.js"; import { qaMockRequestCursorUrl, @@ -111,14 +115,6 @@ type QaRuntimeToolFixtureDeps = { ensureImageGenerationConfigured: (env: QaSuiteRuntimeEnv) => Promise; }; -function readString(raw: unknown, fallback = "") { - return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : fallback; -} - -function readBoolean(raw: unknown, fallback: boolean) { - return typeof raw === "boolean" ? raw : fallback; -} - function isKnownBroken(raw: unknown): raw is Record { return isRecord(raw); } @@ -813,8 +809,8 @@ function formatKnownBrokenDetails( config: QaRuntimeToolFixtureConfig, ) { const knownBroken = isKnownBroken(config.knownBroken) ? config.knownBroken : {}; - const issue = readString(knownBroken.issue); - const reason = readString(knownBroken.reason, "known broken runtime tool fixture"); + const issue = normalizeOptionalString(knownBroken.issue) ?? ""; + const reason = normalizeOptionalString(knownBroken.reason) ?? "known broken runtime tool fixture"; return [ `known-broken ${toolName}: ${reason}`, issue ? `tracking: ${issue}` : undefined, @@ -886,8 +882,8 @@ function plannedRequestHasPrompt(request: QaRuntimeToolFixtureRequest) { function formatKnownHarnessGapDetails(toolName: string, config: QaRuntimeToolFixtureConfig) { const knownHarnessGap = isKnownHarnessGap(config.knownHarnessGap) ? config.knownHarnessGap : {}; - const issue = readString(knownHarnessGap.issue); - const reason = readString(knownHarnessGap.reason, "known QA harness gap"); + const issue = normalizeOptionalString(knownHarnessGap.issue) ?? ""; + const reason = normalizeOptionalString(knownHarnessGap.reason) ?? "known QA harness gap"; return [`known-harness-gap ${toolName}: ${reason}`, issue ? `tracking: ${issue}` : undefined] .filter(Boolean) .join("\n"); @@ -898,7 +894,7 @@ export async function runRuntimeToolFixture( config: QaRuntimeToolFixtureConfig, deps: QaRuntimeToolFixtureDeps, ) { - const toolName = readString(config.toolName); + const toolName = normalizeOptionalString(config.toolName) ?? ""; if (!toolName) { throw new Error("runtime tool fixture missing execution.config.toolName"); } @@ -947,7 +943,7 @@ export async function runRuntimeToolFixture( const dynamicExposureIntentionallyExcluded = forcedCodexNativeWorkspace && !tools.has(toolName); const requireCodexNativePatchCoverage = forcedCodexNativeWorkspace && metadata.required && toolName === "apply_patch"; - const expectedAvailable = readBoolean(config.expectedAvailable, true); + const expectedAvailable = asBoolean(config.expectedAvailable) ?? true; if (!tools.has(toolName) && !dynamicExposureIntentionallyExcluded) { if (!expectedAvailable) { skipFixture(formatExpectedUnavailableDetails(toolName, tools)); @@ -965,20 +961,16 @@ export async function runRuntimeToolFixture( ); } - const happyPrompt = readString( - config.happyPrompt, - `tool search qa check target=${toolName}. Call exactly that tool once and then summarize.`, - ); - const failurePrompt = readString( - config.failurePrompt, - `tool search qa failure target=${toolName}. Exercise the denied-input path once and then summarize.`, - ); - const promptSnippet = readString(config.promptSnippet, `target=${toolName}`); - const failurePromptSnippet = readString( - config.failurePromptSnippet, - `failure target=${toolName}`, - ); - const happyPathOutputRequired = readBoolean(config.happyPathOutputRequired, true); + const happyPrompt = + normalizeOptionalString(config.happyPrompt) ?? + `tool search qa check target=${toolName}. Call exactly that tool once and then summarize.`; + const failurePrompt = + normalizeOptionalString(config.failurePrompt) ?? + `tool search qa failure target=${toolName}. Exercise the denied-input path once and then summarize.`; + const promptSnippet = normalizeOptionalString(config.promptSnippet) ?? `target=${toolName}`; + const failurePromptSnippet = + normalizeOptionalString(config.failurePromptSnippet) ?? `failure target=${toolName}`; + const happyPathOutputRequired = asBoolean(config.happyPathOutputRequired) ?? true; // Private QA can expose an actual dynamic patch tool. Real Codex instead // mirrors native file changes into linked apply_patch transcript evidence. const requireNativePatchTranscriptEvidence = !env.mock && requireCodexNativePatchCoverage; diff --git a/extensions/qa-lab/web/src/capture-saved-view.ts b/extensions/qa-lab/web/src/capture-saved-view.ts index 787a67ebc4ed..313afd8c0a5b 100644 --- a/extensions/qa-lab/web/src/capture-saved-view.ts +++ b/extensions/qa-lab/web/src/capture-saved-view.ts @@ -34,7 +34,7 @@ const payloadLayouts = new Set>([ ]); const payloadExtents = new Set(["preview", "full"]); -function readString(value: unknown, maxLength: number): string | null { +function readBoundedString(value: unknown, maxLength: number): string | null { if (typeof value !== "string") { return null; } @@ -73,8 +73,8 @@ export function normalizeCaptureSavedView(value: unknown): CaptureSavedView | nu if (!record) { return null; } - const id = readString(record.id, MAX_FILTER_VALUE_LENGTH); - const name = readString(record.name, MAX_NAME_LENGTH); + const id = readBoundedString(record.id, MAX_FILTER_VALUE_LENGTH); + const name = readBoundedString(record.name, MAX_NAME_LENGTH); if (!id || !name) { return null; } diff --git a/extensions/qqbot/src/bridge/config-shared.ts b/extensions/qqbot/src/bridge/config-shared.ts index 777340ca816a..a5fd7e0d1810 100644 --- a/extensions/qqbot/src/bridge/config-shared.ts +++ b/extensions/qqbot/src/bridge/config-shared.ts @@ -4,6 +4,7 @@ import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/core"; import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { describeAccount as engineDescribeAccount, formatAllowFrom as engineFormatAllowFrom, @@ -13,7 +14,6 @@ import { applySetupAccountConfig as engineApplySetupAccountConfig, validateSetupInput as engineValidateSetupInput, } from "../engine/config/setup-logic.js"; -import { normalizeLowercaseStringOrEmpty } from "../engine/utils/string-normalize.js"; import type { ResolvedQQBotAccount } from "../types.js"; import { listQQBotAccountIds, diff --git a/extensions/qqbot/src/bridge/config.ts b/extensions/qqbot/src/bridge/config.ts index 95ec9906cff3..f2bb97f3c2c5 100644 --- a/extensions/qqbot/src/bridge/config.ts +++ b/extensions/qqbot/src/bridge/config.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth"; import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import { coerceSecretRef, normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getPlatformAdapter } from "../engine/adapter/index.js"; import { DEFAULT_ACCOUNT_ID as ENGINE_DEFAULT_ACCOUNT_ID, @@ -11,7 +12,6 @@ import { resolveAccountBase, resolveDefaultAccountId, } from "../engine/config/resolve.js"; -import { normalizeOptionalString } from "../engine/utils/string-normalize.js"; import type { ResolvedQQBotAccount, QQBotAccountConfig } from "../types.js"; export const DEFAULT_ACCOUNT_ID = ENGINE_DEFAULT_ACCOUNT_ID; diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index 4941fcfe74c1..20cc68bd3bc6 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -11,6 +11,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { channelReadyPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; // Register the PlatformAdapter before any core/ module is used. import "./bridge/bootstrap.js"; import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; @@ -36,7 +37,6 @@ import { looksLikeQQBotTarget, parseTarget, } from "./engine/messaging/target-parser.js"; -import { normalizeOptionalString } from "./engine/utils/string-normalize.js"; import { resolveQQBotGroupToolPolicy } from "./group-policy.js"; import type { ResolvedQQBotAccount } from "./types.js"; diff --git a/extensions/qqbot/src/engine/config/credentials.ts b/extensions/qqbot/src/engine/config/credentials.ts index d869ff1ec542..4e0a62a8098c 100644 --- a/extensions/qqbot/src/engine/config/credentials.ts +++ b/extensions/qqbot/src/engine/config/credentials.ts @@ -7,7 +7,7 @@ * and stay framework-agnostic. */ -import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js"; +import { asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_ACCOUNT_ID } from "./resolve.js"; // ---- Logout: clear all credential fields for an account ---- @@ -29,8 +29,8 @@ export function clearAccountCredentials( accountId: string, ): ClearCredentialsResult { const nextCfg = { ...cfg }; - const channels = asRecord(cfg.channels); - const nextQQBot = channels?.qqbot ? { ...asRecord(channels.qqbot) } : undefined; + const channels = asOptionalObjectRecord(cfg.channels); + const nextQQBot = channels?.qqbot ? { ...asOptionalObjectRecord(channels.qqbot) } : undefined; let cleared = false; let changed = false; diff --git a/extensions/qqbot/src/engine/config/group.ts b/extensions/qqbot/src/engine/config/group.ts index 690f53b96d1f..1684c781b041 100644 --- a/extensions/qqbot/src/engine/config/group.ts +++ b/extensions/qqbot/src/engine/config/group.ts @@ -1,7 +1,6 @@ // Qqbot plugin module implements group behavior. import { resolveScopeRequireMention, type ScopeTree } from "openclaw/plugin-sdk/channel-policy"; -import { asBoolean } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { asOptionalObjectRecord as asRecord } from "../utils/string-normalize.js"; +import { asBoolean, asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveAccountBase } from "./resolve.js"; interface GroupConfig { @@ -36,13 +35,13 @@ function readGroupsMap( accountId?: string | null, ): Record> { const account = resolveAccountBase(cfg, accountId); - const groups = asRecord(account.config.groups); + const groups = asOptionalObjectRecord(account.config.groups); if (!groups) { return {}; } const normalized: Record> = {}; for (const [key, value] of Object.entries(groups)) { - const sub = asRecord(value); + const sub = asOptionalObjectRecord(value); if (sub) { normalized[key] = sub; } @@ -50,11 +49,7 @@ function readGroupsMap( return normalized; } -function readBoolean(obj: Record, key: string): boolean | undefined { - return asBoolean(obj[key]); -} - -function readString(obj: Record, key: string): string | undefined { +function readNonEmptyGroupString(obj: Record, key: string): string | undefined { const v = obj[key]; return typeof v === "string" && v.length > 0 ? v : undefined; } @@ -63,7 +58,7 @@ function readCommandLevel( obj: Record, key: string, ): QQBotGroupCommandLevel | undefined { - const v = readString(obj, key); + const v = readNonEmptyGroupString(obj, key); return v === "all" || v === "safety" || v === "strict" ? v : undefined; } @@ -88,11 +83,11 @@ export function resolveGroupConfig( // 账户级默认值:defaultRequireMention 配置 > 默认 true const accountDefaultRequireMention = asBoolean(account.config.defaultRequireMention); const mentionTree: ScopeTree = { - defaults: { requireMention: readBoolean(wildcard, "requireMention") }, + defaults: { requireMention: asBoolean(wildcard.requireMention) }, scopes: Object.fromEntries( Object.entries(scopes).map(([key, entry]) => [ key, - { requireMention: readBoolean(entry, "requireMention") }, + { requireMention: asBoolean(entry.requireMention) }, ]), ), }; @@ -109,15 +104,19 @@ export function resolveGroupConfig( overrideOrder: "after-config", }), ignoreOtherMentions: - readBoolean(specific, "ignoreOtherMentions") ?? - readBoolean(wildcard, "ignoreOtherMentions") ?? + asBoolean(specific.ignoreOtherMentions) ?? + asBoolean(wildcard.ignoreOtherMentions) ?? DEFAULT_GROUP_CONFIG.ignoreOtherMentions, commandLevel: readCommandLevel(specific, "commandLevel") ?? readCommandLevel(wildcard, "commandLevel") ?? DEFAULT_GROUP_CONFIG.commandLevel, - name: readString(specific, "name") ?? readString(wildcard, "name") ?? DEFAULT_GROUP_CONFIG.name, - prompt: readString(specific, "prompt") ?? readString(wildcard, "prompt"), + name: + readNonEmptyGroupString(specific, "name") ?? + readNonEmptyGroupString(wildcard, "name") ?? + DEFAULT_GROUP_CONFIG.name, + prompt: + readNonEmptyGroupString(specific, "prompt") ?? readNonEmptyGroupString(wildcard, "prompt"), historyLimit: readHistoryLimit(specific, "historyLimit") ?? readHistoryLimit(wildcard, "historyLimit") ?? @@ -129,9 +128,9 @@ export function resolveGroupCommandLevelFromAccountConfig( accountConfig: Record | undefined, groupOpenid?: string | null, ): QQBotGroupCommandLevel { - const groups = asRecord(accountConfig?.groups); - const wildcard = asRecord(groups?.["*"]) ?? {}; - const specific = groupOpenid ? (asRecord(groups?.[groupOpenid]) ?? {}) : {}; + const groups = asOptionalObjectRecord(accountConfig?.groups); + const wildcard = asOptionalObjectRecord(groups?.["*"]) ?? {}; + const specific = groupOpenid ? (asOptionalObjectRecord(groups?.[groupOpenid]) ?? {}) : {}; return ( readCommandLevel(specific, "commandLevel") ?? readCommandLevel(wildcard, "commandLevel") ?? @@ -181,7 +180,7 @@ export function resolveMentionPatterns( agentId?: string | null, ): string[] { if (agentId) { - const agents = asRecord(cfg.agents); + const agents = asOptionalObjectRecord(cfg.agents); const list = Array.isArray(agents?.list) ? (agents?.list as AgentEntry[]) : []; const entry = list.find( (a) => typeof a.id === "string" && a.id.trim().toLowerCase() === agentId.trim().toLowerCase(), @@ -195,8 +194,8 @@ export function resolveMentionPatterns( } } - const messages = asRecord(cfg.messages); - const globalGroupChat = asRecord(messages?.groupChat); + const messages = asOptionalObjectRecord(cfg.messages); + const globalGroupChat = asOptionalObjectRecord(messages?.groupChat); if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) { const patterns = globalGroupChat.mentionPatterns; return Array.isArray(patterns) diff --git a/extensions/qqbot/src/engine/config/resolve.ts b/extensions/qqbot/src/engine/config/resolve.ts index 987f7da2bb62..7a1210c99afb 100644 --- a/extensions/qqbot/src/engine/config/resolve.ts +++ b/extensions/qqbot/src/engine/config/resolve.ts @@ -8,14 +8,14 @@ * this module stays framework-agnostic and self-contained. */ -import { getPlatformAdapter } from "../adapter/index.js"; import { - asOptionalObjectRecord as asRecord, + asOptionalObjectRecord, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringifiedEntries, - readStringField as readString, -} from "../utils/string-normalize.js"; + readStringField, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { getPlatformAdapter } from "../adapter/index.js"; /** * Default account ID, used for the unnamed top-level account. @@ -73,7 +73,7 @@ function normalizeAccountConfig( if (!account) { return {}; } - const audioPolicy = asRecord(account.audioFormatPolicy); + const audioPolicy = asOptionalObjectRecord(account.audioFormatPolicy); return { ...account, ...(audioPolicy ? { audioFormatPolicy: { ...audioPolicy } } : {}), @@ -81,8 +81,8 @@ function normalizeAccountConfig( } function readQQBotSection(cfg: Record): QQBotChannelConfig | undefined { - const channels = asRecord(cfg.channels); - return asRecord(channels?.qqbot) as QQBotChannelConfig | undefined; + const channels = asOptionalObjectRecord(cfg.channels); + return asOptionalObjectRecord(channels?.qqbot) as QQBotChannelConfig | undefined; } function readOwnAccounts( @@ -91,7 +91,7 @@ function readOwnAccounts( if (!qqbot || !Object.hasOwn(qqbot, "accounts")) { return undefined; } - return asRecord(qqbot.accounts) as QQBotChannelConfig["accounts"] | undefined; + return asOptionalObjectRecord(qqbot.accounts) as QQBotChannelConfig["accounts"] | undefined; } function readOwnAccountConfig( @@ -102,7 +102,7 @@ function readOwnAccountConfig( if (!accounts || !Object.hasOwn(accounts, accountId)) { return undefined; } - const account = asRecord(accounts[accountId]); + const account = asOptionalObjectRecord(accounts[accountId]); return account ? { ...account } : undefined; } @@ -177,7 +177,7 @@ export function resolveAccountBase( if (resolvedAccountId === DEFAULT_ACCOUNT_ID) { accountConfig = normalizeAccountConfig({ - ...asRecord(qqbot), + ...asOptionalObjectRecord(qqbot), ...readOwnAccountConfig(qqbot, DEFAULT_ACCOUNT_ID), }); appId = normalizeAppId(accountConfig.appId); @@ -193,10 +193,10 @@ export function resolveAccountBase( return { accountId: resolvedAccountId, - name: readString(accountConfig, "name"), + name: readStringField(accountConfig, "name"), enabled: accountConfig.enabled !== false, appId, - systemPrompt: readString(accountConfig, "systemPrompt"), + systemPrompt: readStringField(accountConfig, "systemPrompt"), markdownSupport: accountConfig.markdownSupport !== false, config: accountConfig, }; @@ -218,8 +218,8 @@ export function applyAccountConfig( input: ApplyAccountInput, ): Record { const next = { ...cfg }; - const channels = asRecord(cfg.channels) ?? {}; - const existingQQBot = asRecord(channels.qqbot) ?? {}; + const channels = asOptionalObjectRecord(cfg.channels) ?? {}; + const existingQQBot = asOptionalObjectRecord(channels.qqbot) ?? {}; if (accountId === DEFAULT_ACCOUNT_ID) { const allowFrom = (existingQQBot.allowFrom as unknown[]) ?? ["*"]; diff --git a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts index b1a14146c31c..1fb589e7a290 100644 --- a/extensions/qqbot/src/engine/gateway/inbound-attachments.ts +++ b/extensions/qqbot/src/engine/gateway/inbound-attachments.ts @@ -1,11 +1,11 @@ // Qqbot plugin module implements inbound attachments behavior. import { normalizeMimeType } from "openclaw/plugin-sdk/media-mime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { AudioConvertPort } from "../adapter/audio.port.js"; import { downloadFile } from "../utils/file-utils.js"; import { getQQBotMediaDir } from "../utils/platform.js"; -import { normalizeOptionalString } from "../utils/string-normalize.js"; import { transcribeAudio, resolveSTTConfig } from "../utils/stt.js"; interface RawAttachment { diff --git a/extensions/qqbot/src/engine/messaging/outbound-deliver.ts b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts index 2f861932495d..1bc4f4f88828 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-deliver.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-deliver.ts @@ -11,15 +11,15 @@ import { sendPayloadMediaSequence, sendPayloadTextChunkSequence, } from "openclaw/plugin-sdk/reply-payload"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { GatewayAccount } from "../types.js"; import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "../utils/image-size.js"; import { normalizeMediaTags } from "../utils/media-tags.js"; import { isLocalPath as isLocalFilePath } from "../utils/platform.js"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "../utils/string-normalize.js"; import { filterInternalMarkers } from "../utils/text-parsing.js"; import { decodeMediaPath } from "./decode-media-path.js"; import { DEFAULT_MEDIA_SEND_ERROR, type OutboundMediaAccessContext } from "./outbound-types.js"; diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts index 237407aff1d1..1383c483e7e4 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts @@ -14,6 +14,7 @@ import { sanitizeUntrustedFileName, writeExternalFileWithinRoot, } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { GatewayAccount } from "../types.js"; import { MediaFileType } from "../types.js"; @@ -33,7 +34,7 @@ import { isLocalPath as isLocalFilePath, normalizePath, } from "../utils/platform.js"; -import { normalizeLowercaseStringOrEmpty, sanitizeFileName } from "../utils/string-normalize.js"; +import { sanitizeFileName } from "../utils/string-normalize.js"; import { audioFileToSilkBase64, shouldTranscodeVoice, waitForFile } from "./outbound-audio-port.js"; import { isPathWithinRoot, diff --git a/extensions/qqbot/src/engine/messaging/outbound.ts b/extensions/qqbot/src/engine/messaging/outbound.ts index b5ed81b2a9cb..5d635405ecae 100644 --- a/extensions/qqbot/src/engine/messaging/outbound.ts +++ b/extensions/qqbot/src/engine/messaging/outbound.ts @@ -34,6 +34,10 @@ export { } from "./outbound-media-send.js"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { qqbotNotConfiguredMessage } from "../config/setup-guidance.js"; import type { GatewayAccount } from "../types.js"; @@ -41,10 +45,6 @@ import type { EngineLogger } from "../types.js"; import { debugError, debugLog, debugWarn } from "../utils/log.js"; import { normalizeMediaTags } from "../utils/media-tags.js"; import { decodeCronPayload } from "../utils/payload.js"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "../utils/string-normalize.js"; import { decodeMediaPath } from "./decode-media-path.js"; import { isImageFile as coreIsImageFile, diff --git a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts index fe6bd54953d9..cd9b461d87fa 100644 --- a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts +++ b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts @@ -9,6 +9,7 @@ import crypto from "node:crypto"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { MediaFileType, type GatewayAccount } from "../types.js"; import { formatFileSize, getImageMimeType, getMaxUploadSize } from "../utils/file-utils.js"; @@ -20,7 +21,6 @@ import { type MediaPayload, } from "../utils/payload.js"; import { normalizePath } from "../utils/platform.js"; -import { normalizeLowercaseStringOrEmpty } from "../utils/string-normalize.js"; import { sanitizeFileName } from "../utils/string-normalize.js"; import { openLocalFile } from "./media-source.js"; import { diff --git a/extensions/qqbot/src/engine/utils/audio.ts b/extensions/qqbot/src/engine/utils/audio.ts index d627cfbfb6e1..6f9ca9ca6a7d 100644 --- a/extensions/qqbot/src/engine/utils/audio.ts +++ b/extensions/qqbot/src/engine/utils/audio.ts @@ -14,8 +14,8 @@ import * as path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; import { readRegularFileSync } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { debugLog, debugError, debugWarn } from "./log.js"; -import { normalizeLowercaseStringOrEmpty as normalizeLowercase } from "./string-normalize.js"; type SilkWasm = typeof import("silk-wasm"); let silkWasmPromise: Promise | null = null; @@ -116,11 +116,11 @@ export function isVoiceAttachment(att: { content_type?: string; filename?: strin // MIME types are case-insensitive (RFC 2045) and relays may emit mixed-case // values; the bare "voice" platform sentinel gets the same treatment. // Compare lowercased like the extension check below. - const contentType = normalizeLowercase(att.content_type); + const contentType = normalizeLowercaseStringOrEmpty(att.content_type); if (contentType === "voice" || contentType.startsWith("audio/")) { return true; } - const ext = att.filename ? normalizeLowercase(path.extname(att.filename)) : ""; + const ext = att.filename ? normalizeLowercaseStringOrEmpty(path.extname(att.filename)) : ""; return [".amr", ".silk", ".slk", ".slac"].includes(ext); } @@ -131,7 +131,7 @@ export function isAudioFile(filePath: string, mimeType?: string): boolean { return true; } } - const ext = normalizeLowercase(path.extname(filePath)); + const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); return [ ".silk", ".slk", @@ -162,10 +162,10 @@ const QQ_NATIVE_VOICE_EXTS = new Set([".silk", ".slk", ".amr", ".wav", ".mp3"]); /** Check whether a voice file needs transcoding for upload (QQ-native formats skip it). */ export function shouldTranscodeVoice(filePath: string, mimeType?: string): boolean { - if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercase(mimeType))) { + if (mimeType && QQ_NATIVE_VOICE_MIMES.has(normalizeLowercaseStringOrEmpty(mimeType))) { return false; } - const ext = normalizeLowercase(path.extname(filePath)); + const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); if (QQ_NATIVE_VOICE_EXTS.has(ext)) { return false; } @@ -176,7 +176,7 @@ const QQ_NATIVE_UPLOAD_FORMATS = [".wav", ".mp3", ".silk"]; function normalizeFormats(formats: string[]): string[] { return formats.map((f) => { - const lower = normalizeLowercase(f); + const lower = normalizeLowercaseStringOrEmpty(f); return lower.startsWith(".") ? lower : `.${lower}`; }); } @@ -202,7 +202,7 @@ export async function audioFileToSilkBase64( return null; } - const ext = normalizeLowercase(path.extname(filePath)); + const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); const uploadFormats = directUploadFormats ? normalizeFormats(directUploadFormats) : QQ_NATIVE_UPLOAD_FORMATS; diff --git a/extensions/qqbot/src/engine/utils/file-utils.ts b/extensions/qqbot/src/engine/utils/file-utils.ts index 911ccb61264b..77a95ff1e6f7 100644 --- a/extensions/qqbot/src/engine/utils/file-utils.ts +++ b/extensions/qqbot/src/engine/utils/file-utils.ts @@ -10,10 +10,10 @@ import { readRegularFile, statRegularFileSync, } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { getPlatformAdapter } from "../adapter/index.js"; import type { SsrfPolicyConfig } from "../adapter/types.js"; import { MediaFileType } from "../types.js"; -import { normalizeOptionalString } from "./string-normalize.js"; /** Maximum file size accepted by the QQ Bot one-shot upload API (base64 direct). */ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; diff --git a/extensions/qqbot/src/engine/utils/string-normalize.ts b/extensions/qqbot/src/engine/utils/string-normalize.ts index 4ab024d4d2b8..c8bd84ee5a36 100644 --- a/extensions/qqbot/src/engine/utils/string-normalize.ts +++ b/extensions/qqbot/src/engine/utils/string-normalize.ts @@ -1,77 +1,4 @@ -/** - * String normalization and record-coercion helpers. - * - * These are self-contained re-implementations of the functions that - * the plugin previously imported from broad SDK text barrels - * and shared record/string coercion helpers. - * - * core/ modules use these instead of importing plugin-sdk, keeping the - * shared layer portable between the built-in and standalone versions. - */ - -// ---- String coercion ---- - -/** Return the trimmed string or `null` when the value is not a non-empty string. */ -function normalizeNullableString(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} - -/** Return the trimmed string or `undefined` when the value is not a non-empty string. */ -export function normalizeOptionalString(value: unknown): string | undefined { - return normalizeNullableString(value) ?? undefined; -} - -/** - * Stringify then normalize. Accepts `string | number | boolean | bigint`. - * Returns `undefined` for objects, arrays, null, and undefined. - */ -function normalizeStringifiedOptionalString(value: unknown): string | undefined { - if (typeof value === "string") { - return normalizeOptionalString(value); - } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return normalizeOptionalString(String(value)); - } - return undefined; -} - -export function normalizeStringifiedEntries(values?: ReadonlyArray): string[] { - return (values ?? []) - .map((entry) => normalizeStringifiedOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)); -} - -/** Return the trimmed lowercase string or `undefined`. */ -export function normalizeOptionalLowercaseString(value: unknown): string | undefined { - return normalizeOptionalString(value)?.toLowerCase(); -} - -/** Return the trimmed lowercase string or `""`. */ -export function normalizeLowercaseStringOrEmpty(value: unknown): string { - return normalizeOptionalLowercaseString(value) ?? ""; -} - -// ---- Record coercion ---- - -/** Coerce a value into a `Record` or `undefined`. */ -export function asOptionalObjectRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - -/** Read a string field from a record. */ -export function readStringField( - record: Record | null | undefined, - key: string, -): string | undefined { - const v = record?.[key]; - return typeof v === "string" ? v : undefined; -} - -// ---- Filename normalization ---- +// Filename normalization specific to QQ Bot's upload API. /** * Normalize filenames into a UTF-8 form that the QQ Bot API accepts reliably. diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts index 3a438f99e16a..247c666bf241 100644 --- a/extensions/qqbot/src/engine/utils/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -1,8 +1,7 @@ /** * OpenAI-compatible STT (Speech-to-Text) configuration and transcription. * - * Migrated from `src/stt.ts` — uses core/utils/string-normalize instead - * of broad SDK text barrels. + * Uses canonical Plugin SDK coercion helpers plus QQ-specific filename sanitization. */ import * as fs from "node:fs"; @@ -14,13 +13,13 @@ import { readResponseTextLimited, } from "openclaw/plugin-sdk/provider-http"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; -import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { + asOptionalObjectRecord, normalizeOptionalString, - asOptionalObjectRecord as asRecord, - readStringField as readString, - sanitizeFileName, -} from "./string-normalize.js"; + readStringField, +} from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import { sanitizeFileName } from "./string-normalize.js"; const STT_ERROR_BODY_LIMIT_BYTES = 8 * 1024; const DEFAULT_STT_TIMEOUT_MS = 60_000; @@ -44,19 +43,20 @@ function resolveSTTTimeoutMs(...timeoutSeconds: unknown[]): number { /** Resolve the STT configuration from the nested config object. */ export function resolveSTTConfig(cfg: Record): STTConfig | null { - const channels = asRecord(cfg.channels); - const qqbot = asRecord(channels?.qqbot); - const channelStt = asRecord(qqbot?.stt); - const models = asRecord(cfg.models); - const providers = asRecord(models?.providers); + const channels = asOptionalObjectRecord(cfg.channels); + const qqbot = asOptionalObjectRecord(channels?.qqbot); + const channelStt = asOptionalObjectRecord(qqbot?.stt); + const models = asOptionalObjectRecord(cfg.models); + const providers = asOptionalObjectRecord(models?.providers); // Prefer plugin-specific STT config. if (channelStt && channelStt.enabled !== false) { - const providerId = readString(channelStt, "provider") ?? "openai"; - const providerCfg = asRecord(providers?.[providerId]); - const baseUrl = readString(channelStt, "baseUrl") ?? readString(providerCfg, "baseUrl"); - const apiKey = readString(channelStt, "apiKey") ?? readString(providerCfg, "apiKey"); - const model = readString(channelStt, "model") ?? "whisper-1"; + const providerId = readStringField(channelStt, "provider") ?? "openai"; + const providerCfg = asOptionalObjectRecord(providers?.[providerId]); + const baseUrl = + readStringField(channelStt, "baseUrl") ?? readStringField(providerCfg, "baseUrl"); + const apiKey = readStringField(channelStt, "apiKey") ?? readStringField(providerCfg, "apiKey"); + const model = readStringField(channelStt, "model") ?? "whisper-1"; if (baseUrl && apiKey) { return { baseUrl: baseUrl.replace(/\/+$/, ""), @@ -68,19 +68,21 @@ export function resolveSTTConfig(cfg: Record): STTConfig | null } // Fall back to framework-level audio model config. - const tools = asRecord(cfg.tools); - const media = asRecord(tools?.media); - const audio = asRecord(media?.audio); + const tools = asOptionalObjectRecord(cfg.tools); + const media = asOptionalObjectRecord(tools?.media); + const audio = asOptionalObjectRecord(media?.audio); const mediaModels = Array.isArray(media?.models) ? media.models : []; const audioModelEntry = mediaModels - .map((entry) => asRecord(entry)) + .map((entry) => asOptionalObjectRecord(entry)) .find((entry) => !Array.isArray(entry?.capabilities) || entry.capabilities.includes("audio")); if (audioModelEntry) { - const providerId = readString(audioModelEntry, "provider") ?? "openai"; - const providerCfg = asRecord(providers?.[providerId]); - const baseUrl = readString(audioModelEntry, "baseUrl") ?? readString(providerCfg, "baseUrl"); - const apiKey = readString(audioModelEntry, "apiKey") ?? readString(providerCfg, "apiKey"); - const model = readString(audioModelEntry, "model") ?? "whisper-1"; + const providerId = readStringField(audioModelEntry, "provider") ?? "openai"; + const providerCfg = asOptionalObjectRecord(providers?.[providerId]); + const baseUrl = + readStringField(audioModelEntry, "baseUrl") ?? readStringField(providerCfg, "baseUrl"); + const apiKey = + readStringField(audioModelEntry, "apiKey") ?? readStringField(providerCfg, "apiKey"); + const model = readStringField(audioModelEntry, "model") ?? "whisper-1"; if (baseUrl && apiKey) { return { baseUrl: baseUrl.replace(/\/+$/, ""), diff --git a/extensions/reef/src/flow.ts b/extensions/reef/src/flow.ts index 879eb1872e51..96b6a05d1fa7 100644 --- a/extensions/reef/src/flow.ts +++ b/extensions/reef/src/flow.ts @@ -1,4 +1,7 @@ -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { appendAudit, appendInboxRead, @@ -42,12 +45,6 @@ interface LegacyDeliveryCandidate { expiresAt: number; } -function asRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function buildLegacyDeliveryIndex( entries: readonly AuditEntry[], ): Map { @@ -57,12 +54,12 @@ function buildLegacyDeliveryIndex( const candidates = new Map(); for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]!; - const payload = asRecord(entry.event.payload); + const payload = asOptionalRecord(entry.event.payload); if (entry.event.type === "confirm_delivery") { if (entry.event.ts < oldest) { continue; } - const receipt = asRecord(payload?.receipt); + const receipt = asOptionalRecord(payload?.receipt); if (typeof receipt?.id === "string") { confirmed.add(receipt.id); sealed.delete(receipt.id); diff --git a/extensions/signal/src/config-compat.ts b/extensions/signal/src/config-compat.ts index a7a1800150b9..d17ea92dec9d 100644 --- a/extensions/signal/src/config-compat.ts +++ b/extensions/signal/src/config-compat.ts @@ -2,7 +2,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco // Signal compatibility migration moves shipped flat transport config into account ownership. import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { SignalTransportConfig } from "./account-types.js"; import { allocateSignalManagedNativePort, @@ -77,20 +77,16 @@ function isSignalTransportConfig(value: unknown): value is SignalTransportConfig } } -function optionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function inherited(entry: Record, parent: Record, key: string) { return Object.hasOwn(entry, key) ? entry[key] : parent[key]; } function legacyBaseUrl(entry: Record, parent: Record): string { - const url = optionalString(inherited(entry, parent, "httpUrl")); + const url = normalizeOptionalString(inherited(entry, parent, "httpUrl")); if (url) { return normalizeSignalTransportUrl(url); } - const host = optionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; + const host = normalizeOptionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; const rawPort = inherited(entry, parent, "httpPort"); const port = typeof rawPort === "number" ? rawPort : 8080; return buildSignalTransportHttpUrl(host, port); @@ -105,11 +101,11 @@ function wasLegacySignalAccountConfigured( parent: Record, ): boolean { return Boolean( - optionalString(inherited(entry, parent, "account")) || - optionalString(inherited(entry, parent, "configPath")) || - optionalString(inherited(entry, parent, "httpUrl")) || - optionalString(inherited(entry, parent, "httpHost")) || - optionalString(inherited(entry, parent, "cliPath")) || + normalizeOptionalString(inherited(entry, parent, "account")) || + normalizeOptionalString(inherited(entry, parent, "configPath")) || + normalizeOptionalString(inherited(entry, parent, "httpUrl")) || + normalizeOptionalString(inherited(entry, parent, "httpHost")) || + normalizeOptionalString(inherited(entry, parent, "cliPath")) || typeof inherited(entry, parent, "httpPort") === "number" || typeof inherited(entry, parent, "autoStart") === "boolean", ); @@ -120,7 +116,7 @@ function hasInvalidLegacyHttpUrl( parent: Record, ): boolean { return entries.some((entry) => { - const httpUrl = optionalString(inherited(entry, parent, "httpUrl")); + const httpUrl = normalizeOptionalString(inherited(entry, parent, "httpUrl")); if (!httpUrl) { return false; } @@ -138,14 +134,14 @@ function findInvalidLegacyDerivedEndpoint( parent: Record, ): "host" | "port" | undefined { for (const entry of entries) { - if (optionalString(inherited(entry, parent, "httpUrl"))) { + if (normalizeOptionalString(inherited(entry, parent, "httpUrl"))) { continue; } const rawPort = inherited(entry, parent, "httpPort"); if (rawPort !== undefined && !isValidSignalManagedNativePort(rawPort)) { return "port"; } - const host = optionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; + const host = normalizeOptionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; try { buildSignalTransportHttpUrl(host, typeof rawPort === "number" ? rawPort : 8080); } catch { @@ -175,7 +171,7 @@ function requiresDetection( return false; } return ( - Boolean(optionalString(inherited(entry, parent, "httpUrl"))) || + Boolean(normalizeOptionalString(inherited(entry, parent, "httpUrl"))) || !resolveLegacyAutoStart(entry, parent) ); } @@ -188,20 +184,20 @@ function resolveLegacyAutoStart( if (typeof autoStart === "boolean") { return autoStart; } - return !optionalString(inherited(entry, parent, "httpUrl")); + return !normalizeOptionalString(inherited(entry, parent, "httpUrl")); } function resolveManagedConnectionUrl( entry: Record, parent: Record, ): string | undefined { - const httpUrl = optionalString(inherited(entry, parent, "httpUrl")); + const httpUrl = normalizeOptionalString(inherited(entry, parent, "httpUrl")); if (!httpUrl) { return undefined; } const normalizedUrl = normalizeSignalTransportUrl(httpUrl); const endpoint = new URL(normalizedUrl); - const bindHost = (optionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1") + const bindHost = (normalizeOptionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1") .replace(/^\[|\]$/g, "") .toLowerCase(); const rawBindPort = inherited(entry, parent, "httpPort"); @@ -222,10 +218,10 @@ function buildManagedNativeTransport( parent: Record, ): SignalTransportConfig { const value = (key: string) => inherited(entry, parent, key); - const configPath = optionalString(value("configPath")); - const cliPath = optionalString(value("cliPath")); + const configPath = normalizeOptionalString(value("configPath")); + const cliPath = normalizeOptionalString(value("cliPath")); const url = resolveManagedConnectionUrl(entry, parent); - const httpHost = optionalString(value("httpHost")); + const httpHost = normalizeOptionalString(value("httpHost")); const httpPort = value("httpPort"); const startupTimeoutMs = value("startupTimeoutMs"); const receiveMode = value("receiveMode"); @@ -280,7 +276,7 @@ async function resolveLegacyTransport(params: { if (resolved) { return resolved; } - const account = optionalString(inherited(params.entry, params.parent, "account")); + const account = normalizeOptionalString(inherited(params.entry, params.parent, "account")); try { const detected = await params.detect?.({ url: legacyBaseUrl(params.entry, params.parent), @@ -315,7 +311,7 @@ function hasRootSignalAccount(entries: Record[]): boolean { const root = entries[0]; return ( entries.length === 1 || - Boolean(optionalString(root?.account)) || + Boolean(normalizeOptionalString(root?.account)) || isSignalTransportConfig(root?.transport) ); } @@ -525,8 +521,8 @@ function hasContainerTransportWithoutEffectiveAccount(cfg: OpenClawConfig): bool const defaultEntry = defaultKey ? accounts[defaultKey] : undefined; const defaultAccount = isRecord(defaultEntry) && defaultEntry.account !== undefined - ? optionalString(defaultEntry.account) - : optionalString(signal.account); + ? normalizeOptionalString(defaultEntry.account) + : normalizeOptionalString(signal.account); const channelEnabled = signal.enabled !== false; const defaultEnabled = !isRecord(defaultEntry) || defaultEntry.enabled !== false; if (rootTransport?.kind === "container" && channelEnabled && defaultEnabled && !defaultAccount) { @@ -550,7 +546,9 @@ function hasContainerTransportWithoutEffectiveAccount(cfg: OpenClawConfig): bool continue; } const account = - entry.account === undefined ? optionalString(signal.account) : optionalString(entry.account); + entry.account === undefined + ? normalizeOptionalString(signal.account) + : normalizeOptionalString(entry.account); if (!account) { return true; } diff --git a/extensions/slack/src/agent-context.ts b/extensions/slack/src/agent-context.ts index 6e3ebc1dfaeb..863465652bce 100644 --- a/extensions/slack/src/agent-context.ts +++ b/extensions/slack/src/agent-context.ts @@ -1,4 +1,8 @@ // Slack plugin module normalizes Agent View active-context entities. +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; export type SlackAppContext = { entities?: unknown; @@ -19,29 +23,19 @@ type SlackAppContextEntity = team_id?: string; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function trimmedString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function normalizeEntity(value: unknown): SlackAppContextEntity | undefined { - const entity = asRecord(value); - const type = trimmedString(entity?.type); + const entity = asOptionalRecord(value); + const type = normalizeOptionalString(entity?.type); if (!entity || !type) { return undefined; } - const teamId = trimmedString(entity.team_id); + const teamId = normalizeOptionalString(entity.team_id); if ( type === "slack#/types/channel_id" || type === "slack#/types/canvas_id" || type === "slack#/types/list_id" ) { - const entityValue = trimmedString(entity.value); + const entityValue = normalizeOptionalString(entity.value); return entityValue ? { type, value: entityValue, ...(teamId ? { team_id: teamId } : {}) } : undefined; @@ -49,9 +43,9 @@ function normalizeEntity(value: unknown): SlackAppContextEntity | undefined { if (type !== "slack#/types/message_context") { return undefined; } - const message = asRecord(entity.value); - const channelId = trimmedString(message?.channel_id); - const messageTs = trimmedString(message?.message_ts); + const message = asOptionalRecord(entity.value); + const channelId = normalizeOptionalString(message?.channel_id); + const messageTs = normalizeOptionalString(message?.message_ts); return channelId && messageTs ? { type, @@ -62,11 +56,11 @@ function normalizeEntity(value: unknown): SlackAppContextEntity | undefined { } export function isSlackAppContext(value: unknown): value is SlackAppContext { - return Boolean(asRecord(value)); + return Boolean(asOptionalRecord(value)); } export function normalizeSlackAppContextEntities(value: unknown): SlackAppContextEntity[] { - const context = asRecord(value); + const context = asOptionalRecord(value); if (!Array.isArray(context?.entities)) { return []; } diff --git a/extensions/slack/src/blocks-fallback.ts b/extensions/slack/src/blocks-fallback.ts index de879946f8f1..590481149105 100644 --- a/extensions/slack/src/blocks-fallback.ts +++ b/extensions/slack/src/blocks-fallback.ts @@ -1,4 +1,8 @@ // Slack plugin module implements blocks fallback behavior. +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { renderSlackDataTableFallbackText, renderSlackDataTableMrkdwnFallbackText, @@ -53,29 +57,15 @@ const SLACK_SELECT_ELEMENT_TYPES = new Set([ "multi_channels_select", ]); -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function readNonEmptyString(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - function readTextObject( value: unknown, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - const record = asRecord(value); + const record = asOptionalRecord(value); if (!record) { return undefined; } - const text = readNonEmptyString(record?.text); + const text = normalizeOptionalString(record?.text); if (!text) { return undefined; } @@ -88,7 +78,7 @@ function readTextValue( value: unknown, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - return readNonEmptyString(value) ?? readTextObject(value, options); + return normalizeOptionalString(value) ?? readTextObject(value, options); } function renderSlackRichTextLeaf(element: SlackRichTextElement): string { @@ -97,37 +87,37 @@ function renderSlackRichTextLeaf(element: SlackRichTextElement): string { return typeof element.text === "string" ? escapeSlackMrkdwn(element.text) : ""; case "link": return escapeSlackMrkdwn( - readNonEmptyString(element.text) ?? readNonEmptyString(element.url) ?? "", + normalizeOptionalString(element.text) ?? normalizeOptionalString(element.url) ?? "", ); case "user": { - const userId = readNonEmptyString(element.user_id); + const userId = normalizeOptionalString(element.user_id); return userId ? escapeSlackMrkdwn(`<@${userId}>`) : ""; } case "channel": { - const channelId = readNonEmptyString(element.channel_id); + const channelId = normalizeOptionalString(element.channel_id); return channelId ? escapeSlackMrkdwn(`<#${channelId}>`) : ""; } case "usergroup": { - const usergroupId = readNonEmptyString(element.usergroup_id); + const usergroupId = normalizeOptionalString(element.usergroup_id); return usergroupId ? escapeSlackMrkdwn(``) : ""; } case "broadcast": { - const range = readNonEmptyString(element.range); + const range = normalizeOptionalString(element.range); return range ? escapeSlackMrkdwn(``) : ""; } case "emoji": { - const name = readNonEmptyString(element.name); + const name = normalizeOptionalString(element.name); return name ? `:${name}:` : ""; } case "date": - return escapeSlackMrkdwn(readNonEmptyString(element.fallback) ?? ""); + return escapeSlackMrkdwn(normalizeOptionalString(element.fallback) ?? ""); default: return ""; } } function renderSlackRichTextElement(value: unknown): string { - const element = asRecord(value) as SlackRichTextElement | undefined; + const element = asOptionalRecord(value) as SlackRichTextElement | undefined; if (!element) { return ""; } @@ -151,7 +141,7 @@ function renderSlackRichTextElements(value: unknown, separator: string): string } function readImageText(block: SlackBlockLike): string | undefined { - const altText = readNonEmptyString(block.alt_text); + const altText = normalizeOptionalString(block.alt_text); return (altText ? escapeSlackMrkdwn(altText) : undefined) ?? readTextObject(block.title); } @@ -159,7 +149,7 @@ function readVideoText( block: SlackBlockLike, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - const altText = readNonEmptyString(block.alt_text); + const altText = normalizeOptionalString(block.alt_text); return readTextObject(block.title, options) ?? (altText ? escapeSlackMrkdwn(altText) : undefined); } @@ -172,8 +162,8 @@ function readContextText( } const parts = block.elements .map((element) => { - const record = asRecord(element); - const altText = readNonEmptyString(record?.alt_text); + const record = asOptionalRecord(element); + const altText = normalizeOptionalString(record?.alt_text); return readTextObject(record, options) ?? (altText ? escapeSlackMrkdwn(altText) : undefined); }) .filter((part): part is string => Boolean(part)); @@ -184,8 +174,8 @@ function readControlElementText( value: unknown, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - const element = asRecord(value); - const type = readNonEmptyString(element?.type); + const element = asOptionalRecord(value); + const type = normalizeOptionalString(element?.type); if (type === "button" || type === "workflow_button") { return readTextValue(element?.text, options); } @@ -239,13 +229,13 @@ export function renderSlackBlockFallbackText( raw: unknown, options: RenderSlackBlockFallbackOptions = {}, ): string | undefined { - const block = asRecord(raw) as SlackBlockLike | undefined; + const block = asOptionalRecord(raw) as SlackBlockLike | undefined; if (!block) { return undefined; } switch (block.type) { case "rich_text": - return readNonEmptyString(renderSlackRichTextElements(block.elements, "\n")); + return normalizeOptionalString(renderSlackRichTextElements(block.elements, "\n")); case "header": return readTextObject(block.text, options); case "section": diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index b59fc7d46b33..176464a96bc2 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1,5 +1,6 @@ import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; // Slack tests cover channel plugin behavior. +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { slackPlugin } from "./channel.js"; @@ -158,10 +159,6 @@ function requireSlackListPeers() { return listPeers; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object"); function requireArray(value: unknown, label: string): unknown[] { diff --git a/extensions/slack/src/data-table.ts b/extensions/slack/src/data-table.ts index 6b85cac4846e..3385a203d679 100644 --- a/extensions/slack/src/data-table.ts +++ b/extensions/slack/src/data-table.ts @@ -4,6 +4,7 @@ import { renderMessagePresentationTableFallbackText, type MessagePresentationTableBlock, } from "openclaw/plugin-sdk/interactive-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; import { renderSlackMessagePresentationTableFallbackText } from "./presentation-fallback.js"; @@ -43,12 +44,6 @@ type ParsedSlackDataTable = { cellCharacterCount: number; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } @@ -101,7 +96,7 @@ function readRichTextElements(value: unknown, separator = ""): string { } const parts: string[] = []; for (const rawElement of value) { - const element = asRecord(rawElement); + const element = asOptionalRecord(rawElement); if (!element) { continue; } @@ -124,7 +119,7 @@ function readRichTextElements(value: unknown, separator = ""): string { } function readSlackBasicTableCell(value: unknown): string { - const cell = asRecord(value); + const cell = asOptionalRecord(value); if (!cell) { return ""; } @@ -144,7 +139,7 @@ function readSlackBasicTableCell(value: unknown): string { } function parseSlackBasicTableRows(value: unknown): string[][] | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "table" || !Array.isArray(block.rows)) { return undefined; } @@ -172,7 +167,7 @@ function parseSlackBasicTableRows(value: unknown): string[][] | undefined { } function readSlackDataTableCell(value: unknown, allowRichText: boolean): string | undefined { - const cell = asRecord(value); + const cell = asOptionalRecord(value); if (!cell) { return undefined; } @@ -194,7 +189,7 @@ function parseSlackDataTable( value: unknown, options: { enforceNativeLimits?: boolean } = {}, ): ParsedSlackDataTable | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); const caption = readNonEmptyString(block?.caption); if (block?.type !== "data_table" || !caption || !Array.isArray(block.rows)) { return undefined; @@ -237,7 +232,7 @@ function parseSlackDataTable( /** Detect current native table blocks without depending on unreleased Slack SDK types. */ export function hasSlackDataTableBlock(blocks?: readonly unknown[]): boolean { - return blocks?.some((block) => asRecord(block)?.type === "data_table") ?? false; + return blocks?.some((block) => asOptionalRecord(block)?.type === "data_table") ?? false; } /** Count display characters in one structurally valid native table. */ @@ -352,7 +347,7 @@ export function buildSlackDataTableBlock( /** Extract a deterministic accessible summary from a native Slack table block. */ export function renderSlackDataTableFallbackText(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_table") { return undefined; } @@ -401,7 +396,7 @@ export function renderSlackTableMrkdwnFallbackText(value: unknown): string | und /** Render each native table cell once for bounded, formatting-disabled delivery. */ export function renderSlackDataTableCompactPlainTextFallback(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_table") { return undefined; } @@ -418,7 +413,7 @@ export function renderSlackDataTableCompactPlainTextFallback(value: unknown): st /** Render a native table as mrkdwn without activating raw cell control tokens. */ export function renderSlackDataTableMrkdwnFallbackText(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_table") { return undefined; } diff --git a/extensions/slack/src/data-visualization.ts b/extensions/slack/src/data-visualization.ts index 80a387cbe16a..8fb0fa3bfa7e 100644 --- a/extensions/slack/src/data-visualization.ts +++ b/extensions/slack/src/data-visualization.ts @@ -5,6 +5,7 @@ import { renderMessagePresentationChartFallbackText, type MessagePresentationChartBlock, } from "openclaw/plugin-sdk/interactive-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { escapeSlackMrkdwn } from "./monitor/mrkdwn.js"; import { renderSlackMessagePresentationChartFallbackText } from "./presentation-fallback.js"; @@ -40,15 +41,9 @@ type SlackDataVisualizationBlock = Block & { chart: SlackPieChart | SlackSeriesChart; }; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** Detect native chart blocks without depending on unreleased Slack SDK types. */ export function hasSlackDataVisualizationBlock(blocks?: readonly unknown[]): boolean { - return blocks?.some((block) => asRecord(block)?.type === "data_visualization") ?? false; + return blocks?.some((block) => asOptionalRecord(block)?.type === "data_visualization") ?? false; } function isStringWithin(value: unknown, maxLength: number): value is string { @@ -138,7 +133,7 @@ export function buildSlackDataVisualizationBlock( } function readSlackChartDatum(value: unknown): SlackChartDatum | undefined { - const record = asRecord(value); + const record = asOptionalRecord(value); const label = record?.label; const datumValue = record?.value; return typeof label === "string" && typeof datumValue === "number" @@ -149,9 +144,9 @@ function readSlackChartDatum(value: unknown): SlackChartDatum | undefined { function parseSlackDataVisualizationBlock( value: unknown, ): MessagePresentationChartBlock | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); const title = block?.title; - const chart = asRecord(block?.chart); + const chart = asOptionalRecord(block?.chart); if (block?.type !== "data_visualization" || typeof title !== "string" || !chart) { return undefined; } @@ -172,7 +167,7 @@ function parseSlackDataVisualizationBlock( if (chart.type !== "bar" && chart.type !== "area" && chart.type !== "line") { return undefined; } - const axisConfig = asRecord(chart.axis_config); + const axisConfig = asOptionalRecord(chart.axis_config); const categories = axisConfig?.categories; if (!Array.isArray(categories) || !categories.every((category) => typeof category === "string")) { return undefined; @@ -181,7 +176,7 @@ function parseSlackDataVisualizationBlock( return undefined; } const series = chart.series.map((rawSeries) => { - const seriesRecord = asRecord(rawSeries); + const seriesRecord = asOptionalRecord(rawSeries); if (typeof seriesRecord?.name !== "string" || !Array.isArray(seriesRecord.data)) { return undefined; } @@ -223,7 +218,7 @@ function parseSlackDataVisualizationBlock( /** Extract a deterministic accessible summary from a native Slack chart block. */ export function renderSlackDataVisualizationFallbackText(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_visualization") { return undefined; } @@ -236,7 +231,7 @@ export function renderSlackDataVisualizationFallbackText(value: unknown): string /** Render a native chart as mrkdwn without activating raw data control tokens. */ export function renderSlackDataVisualizationMrkdwnFallbackText(value: unknown): string | undefined { - const block = asRecord(value); + const block = asOptionalRecord(value); if (block?.type !== "data_visualization") { return undefined; } diff --git a/extensions/slack/src/monitor/relay-source.ts b/extensions/slack/src/monitor/relay-source.ts index d4a813e9b3f6..9507a40b71e8 100644 --- a/extensions/slack/src/monitor/relay-source.ts +++ b/extensions/slack/src/monitor/relay-source.ts @@ -7,7 +7,10 @@ import { warn, type RuntimeEnv, } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; import WebSocket, { type ClientOptions, type RawData } from "ws"; import type { SlackSendIdentity } from "../send.js"; @@ -292,16 +295,16 @@ export function parseRelayFrame(data: RawData): unknown { function extractRelaySlackMessageEvent( frame: unknown, ): { deliveryId: string; message: SlackMessageEvent; route: SlackRelayRoute } | undefined { - const record = asRecord(frame); + const record = asOptionalRecord(frame); if (!record || record.type !== "slack_event") { return undefined; } const deliveryId = stringValue(record.delivery_id); - const routeRecord = asRecord(record.route); + const routeRecord = asOptionalRecord(record.route); const routeKind = stringValue(routeRecord?.kind); const routeKey = stringValue(routeRecord?.key); - const payload = asRecord(record.payload); - const event = asRecord(payload?.event); + const payload = asOptionalRecord(record.payload); + const event = asOptionalRecord(payload?.event); if (event?.type !== "message" || typeof event.channel !== "string") { return undefined; } @@ -321,7 +324,7 @@ function extractRelaySlackMessageEvent( function extractRelayHello( frame: unknown, ): { identity: SlackRelayIdentity | undefined } | undefined { - const record = asRecord(frame); + const record = asOptionalRecord(frame); if (!record || record.type !== "hello") { return undefined; } @@ -331,7 +334,8 @@ function extractRelayHello( } function extractRelayIdentity(record: Record): SlackRelayIdentity | undefined { - const identityRecord = asRecord(record.slack_identity) ?? asRecord(record.slackIdentity); + const identityRecord = + asOptionalRecord(record.slack_identity) ?? asOptionalRecord(record.slackIdentity); if (!identityRecord) { return undefined; } @@ -378,12 +382,6 @@ function formatRelayClose(code: number, reason: Buffer): string { : `Slack relay websocket closed (${code})`; } -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function stringValue(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } diff --git a/extensions/slack/src/native-data-blocks.ts b/extensions/slack/src/native-data-blocks.ts index e1bf79c08182..ef2c1fb85e8e 100644 --- a/extensions/slack/src/native-data-blocks.ts +++ b/extensions/slack/src/native-data-blocks.ts @@ -1,5 +1,6 @@ // Shared detection and text fallback for Slack's native chart and table blocks. import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { renderSlackBlockFallbackText } from "./blocks-fallback.js"; import { hasSlackDataTableBlock, @@ -17,12 +18,6 @@ export const SLACK_MALFORMED_NATIVE_DATA_FALLBACK = const SLACK_RESPONSE_URL_BODY_LIMIT_BYTES = 16 * 1024; const SLACK_RESPONSE_URL_BODY_TIMEOUT_MS = 30_000; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** Detect a native Slack chart or table block. */ export function hasSlackNativeDataBlock(blocks?: readonly unknown[]): boolean { return hasSlackDataVisualizationBlock(blocks) || hasSlackDataTableBlock(blocks); @@ -31,18 +26,18 @@ export function hasSlackNativeDataBlock(blocks?: readonly unknown[]): boolean { /** Keep every sibling block while removing Slack's native data blocks. */ export function stripSlackNativeDataBlocks(blocks?: readonly T[]): T[] { return (blocks ?? []).filter((block) => { - const type = asRecord(block)?.type; + const type = asOptionalRecord(block)?.type; return type !== "data_table" && type !== "data_visualization"; }); } /** Match Slack's Web API and response_url `invalid_blocks` error shapes. */ export function isSlackInvalidBlocksError(error: unknown): boolean { - const record = asRecord(error); + const record = asOptionalRecord(error); const rawData = record?.data; - const data = asRecord(rawData); - const rawResponseData = asRecord(record?.response)?.data; - const responseData = asRecord(rawResponseData); + const data = asOptionalRecord(rawData); + const rawResponseData = asOptionalRecord(record?.response)?.data; + const responseData = asOptionalRecord(rawResponseData); const code = data?.error ?? (typeof rawData === "string" ? rawData : undefined) ?? @@ -57,8 +52,8 @@ type SlackResponseLike = { }; function isSlackResponseLike(value: unknown): value is SlackResponseLike { - const record = asRecord(value); - const body = asRecord(record?.body); + const record = asOptionalRecord(value); + const body = asOptionalRecord(record?.body); return ( typeof record?.status === "number" && (typeof record.arrayBuffer === "function" || typeof body?.getReader === "function") @@ -90,13 +85,13 @@ export function isSlackNativeResponseUrlRejection(error: unknown): boolean { if (isSlackInvalidBlocksError(error)) { return true; } - const record = asRecord(error); + const record = asOptionalRecord(error); return record?.code === "slack_bolt_respond_error" && record.statusCode === 400; } /** Extract a complete accessible summary from a supported native data block. */ function renderSlackNativeDataFallbackText(value: unknown): string | undefined { - const type = asRecord(value)?.type; + const type = asOptionalRecord(value)?.type; if (type === "data_visualization") { return renderSlackDataVisualizationMrkdwnFallbackText(value); } @@ -161,7 +156,7 @@ function appendSlackNativeDataFallback( } function renderSlackNativeDataPlainTextBlock(value: unknown): string | undefined { - const type = asRecord(value)?.type; + const type = asOptionalRecord(value)?.type; if (type === "data_table") { return renderSlackDataTableCompactPlainTextFallback(value); } diff --git a/extensions/telegram/src/bot-message-dispatch.test-harness.ts b/extensions/telegram/src/bot-message-dispatch.test-harness.ts index 2ac7166f0874..1e0cb15ad635 100644 --- a/extensions/telegram/src/bot-message-dispatch.test-harness.ts +++ b/extensions/telegram/src/bot-message-dispatch.test-harness.ts @@ -226,7 +226,13 @@ vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => { cfg: resolved.cfg, dispatcherOptions: { ...resolved.dispatcherOptions, - deliver: delivery.deliverWithProviderMessageSending, + deliver: (payload, info) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; + return delivery.deliverWithProviderMessageSending(payload, providerInfo); + }, onError: delivery.onError, }, toolsAllow: resolved.toolsAllow, diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index d65686f24a6f..197997f10e4f 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -115,9 +115,13 @@ const dispatchChannelInboundTurnMock = vi.fn(async DispatchReplyWithBufferedBlockDispatcherParams["dispatcherOptions"]["deliver"] >[1], ) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; const result = "deliverWithProviderMessageSending" in plan.delivery - ? await plan.delivery.deliverWithProviderMessageSending(payload, info) + ? await plan.delivery.deliverWithProviderMessageSending(payload, providerInfo) : await plan.delivery.deliver(payload, info); await plan.delivery.onDelivered?.(payload, info, result); return result; diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts index cc4e69ba3459..3749d4de3165 100644 --- a/extensions/telegram/src/bot-native-commands.test-helpers.ts +++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts @@ -82,16 +82,23 @@ const deliveryMocks = vi.hoisted(() => ({ const dispatchChannelInboundTurnForTest: TelegramNativeCommandDeps["dispatchChannelInboundTurn"] = async (plan) => { + const delivery = plan.delivery; const dispatchResult = await replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher({ ctx: plan.ctxPayload, cfg: plan.cfg, dispatcherOptions: { ...plan.dispatcherOptions, deliver: - "deliverWithProviderMessageSending" in plan.delivery - ? plan.delivery.deliverWithProviderMessageSending - : plan.delivery.deliver, - onError: plan.delivery.onError, + "deliverWithProviderMessageSending" in delivery + ? (payload, info) => { + const providerInfo = { + ...info, + onPlatformSendDispatch: async () => undefined, + }; + return delivery.deliverWithProviderMessageSending(payload, providerInfo); + } + : delivery.deliver, + onError: delivery.onError, }, replyOptions: plan.replyOptions, }); diff --git a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts index d7d9941b1f8c..ba58d6b8f76c 100644 --- a/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts +++ b/extensions/telegram/src/bot.media.downloads-media-file-path-no-file-download.e2e.test.ts @@ -6,7 +6,7 @@ import { setNextSavedMediaPath, telegramBotDepsForTest, telegramMediaHarnessSendMessageSpy, -} from "./bot.media.e2e-harness.js"; +} from "./bot.media.e2e.test-harness.js"; import { TELEGRAM_TEST_TIMINGS, createBotHandler, diff --git a/extensions/telegram/src/bot.media.e2e-harness.ts b/extensions/telegram/src/bot.media.e2e.test-harness.ts similarity index 100% rename from extensions/telegram/src/bot.media.e2e-harness.ts rename to extensions/telegram/src/bot.media.e2e.test-harness.ts diff --git a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts index bc83643d31c2..f7456d846583 100644 --- a/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts +++ b/extensions/telegram/src/bot.media.stickers-and-fragments.e2e.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { readRemoteMediaBufferSpy, telegramBotDepsForTest } from "./bot.media.e2e-harness.js"; +import { readRemoteMediaBufferSpy, telegramBotDepsForTest } from "./bot.media.e2e.test-harness.js"; import { TELEGRAM_TEST_TIMINGS, cacheStickerSpy, diff --git a/extensions/telegram/src/bot.media.test-utils.ts b/extensions/telegram/src/bot.media.test-utils.ts index c9de2f6ac852..cc81a4af0656 100644 --- a/extensions/telegram/src/bot.media.test-utils.ts +++ b/extensions/telegram/src/bot.media.test-utils.ts @@ -2,7 +2,7 @@ import * as ssrf from "openclaw/plugin-sdk/ssrf-runtime"; import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest"; import { telegramBotInfoForTest } from "./bot.create-telegram-bot.test-support.js"; -import * as harness from "./bot.media.e2e-harness.js"; +import * as harness from "./bot.media.e2e.test-harness.js"; type StickerSpy = Mock<(...args: unknown[]) => unknown>; diff --git a/extensions/telegram/src/bot.media.warning-topics.e2e.test.ts b/extensions/telegram/src/bot.media.warning-topics.e2e.test.ts index 203d08f794c1..de4114fcdb98 100644 --- a/extensions/telegram/src/bot.media.warning-topics.e2e.test.ts +++ b/extensions/telegram/src/bot.media.warning-topics.e2e.test.ts @@ -3,7 +3,7 @@ import { readRemoteMediaBufferSpy, telegramBotDepsForTest, telegramMediaHarnessSendMessageSpy, -} from "./bot.media.e2e-harness.js"; +} from "./bot.media.e2e.test-harness.js"; import { createBotHandlerWithOptions } from "./bot.media.test-utils.js"; describe("Telegram single-media warnings", () => { diff --git a/extensions/telegram/src/status-issues.ts b/extensions/telegram/src/status-issues.ts index 10e973bc2ef0..b3cc09fb1616 100644 --- a/extensions/telegram/src/status-issues.ts +++ b/extensions/telegram/src/status-issues.ts @@ -6,12 +6,12 @@ import type { import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { appendMatchMetadata, - asString, isRecord, readAccountStatusSnapshot, resolveEnabledConfiguredAccountId, type AccountStatusSnapshot, } from "openclaw/plugin-sdk/status-helpers"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const TELEGRAM_POLLING_CONNECT_GRACE_MS = 120_000; const TELEGRAM_POLLING_STALE_TRANSPORT_MS = 30 * 60_000; @@ -46,12 +46,12 @@ function asFiniteNumber(value: unknown): number | null { } function appendTelegramRuntimeError(message: string, lastError: unknown): string { - const error = asString(lastError); + const error = normalizeOptionalString(lastError); return error ? `${message}: ${error}` : message; } function isTelegramPollingBacklogStallError(lastError: unknown): boolean { - const error = asString(lastError); + const error = normalizeOptionalString(lastError); return Boolean( error?.includes("isolated polling spool backlog stalled") || error?.includes("isolated polling spool handler timed out"), @@ -65,7 +65,7 @@ function collectTelegramPollingRuntimeIssues(params: { now: number; }) { const { account, accountId, issues, now } = params; - if (account.running !== true || asString(account.mode) !== "polling") { + if (account.running !== true || normalizeOptionalString(account.mode) !== "polling") { return; } @@ -121,7 +121,7 @@ function collectTelegramWebhookRuntimeIssues(params: { now: number; }) { const { account, accountId, issues, now } = params; - if (account.running !== true || asString(account.mode) !== "webhook") { + if (account.running !== true || normalizeOptionalString(account.mode) !== "webhook") { return; } @@ -169,15 +169,15 @@ function readTelegramGroupMembershipAuditSummary( if (!isRecord(entry)) { return null; } - const chatId = asString(entry.chatId); + const chatId = normalizeOptionalString(entry.chatId); if (!chatId) { return null; } const ok = typeof entry.ok === "boolean" ? entry.ok : undefined; - const status = asString(entry.status) ?? null; - const error = asString(entry.error) ?? null; - const matchKey = asString(entry.matchKey) ?? undefined; - const matchSource = asString(entry.matchSource) ?? undefined; + const status = normalizeOptionalString(entry.status) ?? null; + const error = normalizeOptionalString(entry.error) ?? null; + const matchKey = normalizeOptionalString(entry.matchKey); + const matchSource = normalizeOptionalString(entry.matchSource); return { chatId, ok, status, error, matchKey, matchSource }; }) .filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"]) diff --git a/extensions/tlon/src/monitor/cites.ts b/extensions/tlon/src/monitor/cites.ts index 2ccbbbd9a9f6..11dc7c1ee068 100644 --- a/extensions/tlon/src/monitor/cites.ts +++ b/extensions/tlon/src/monitor/cites.ts @@ -1,6 +1,7 @@ // Tlon plugin module implements cites behavior. import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; -import { asRecord, extractCites, extractMessageText, type ParsedCite } from "./utils.js"; +import { asNullableRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { extractCites, extractMessageText, type ParsedCite } from "./utils.js"; type TlonScryApi = { scry: (path: string) => Promise; diff --git a/extensions/tlon/src/monitor/discovery.ts b/extensions/tlon/src/monitor/discovery.ts index 942a677e46bb..5b1854abab8a 100644 --- a/extensions/tlon/src/monitor/discovery.ts +++ b/extensions/tlon/src/monitor/discovery.ts @@ -1,7 +1,8 @@ // Tlon plugin module implements discovery behavior. import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; +import { asNullableRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { Foreigns } from "../urbit/foreigns.js"; -import { asRecord, formatErrorMessage } from "./utils.js"; +import { formatErrorMessage } from "./utils.js"; interface InitData { channels: string[]; diff --git a/extensions/tlon/src/monitor/history.ts b/extensions/tlon/src/monitor/history.ts index 0486159d0a5d..80899e9f001b 100644 --- a/extensions/tlon/src/monitor/history.ts +++ b/extensions/tlon/src/monitor/history.ts @@ -1,6 +1,7 @@ // Tlon plugin module implements history behavior. import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; -import { asRecord, extractMessageText, formatErrorMessage } from "./utils.js"; +import { asNullableRecord as asRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { extractMessageText, formatErrorMessage } from "./utils.js"; /** * Format a number as @ud (with dots every 3 digits from the right) diff --git a/extensions/tlon/src/monitor/index.ts b/extensions/tlon/src/monitor/index.ts index 4b13a5cee6ff..79d9c49fa259 100644 --- a/extensions/tlon/src/monitor/index.ts +++ b/extensions/tlon/src/monitor/index.ts @@ -7,7 +7,11 @@ import { import type { GetReplyOptions, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime"; import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env"; -import { asFiniteNumber } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + asFiniteNumber, + asNullableRecord as asRecord, + readStringField as readString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { OpenClawConfig } from "../../runtime-api.js"; import { createLoggerBackedRuntime } from "../../runtime-api.js"; @@ -40,7 +44,7 @@ import { shouldMigrateTlonSetting, } from "./settings-helpers.js"; import { createActiveSnapshotTracker, createParticipatedThreadTracker } from "./tracking.js"; -import { asRecord, formatErrorMessage, readString } from "./utils.js"; +import { formatErrorMessage } from "./utils.js"; import { extractMessageText, formatModelName, @@ -61,10 +65,6 @@ type MonitorTlonOpts = { accountId?: string | null; }; -function readNumber(record: Record | null, key: string): number | undefined { - return asFiniteNumber(record?.[key]); -} - export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise { const core = getTlonRuntime(); const cfg = core.config.current() as OpenClawConfig; @@ -793,7 +793,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function readStringField( - record: Record | null | undefined, - field: string, -): string | undefined { - const value = record?.[field]; - return typeof value === "string" ? value : undefined; -} // Helper to recursively extract text from inline content function renderInlineItem( @@ -272,11 +257,11 @@ function renderInlineItem( if (typeof item === "string") { return item; } - const record = asRecord(item); + const record = asNullableRecord(item); if (!record) { return ""; } - const ship = readString(record, "ship"); + const ship = readStringField(record, "ship"); if (ship) { return ship; } @@ -292,18 +277,18 @@ function renderInlineItem( if (options?.allowBreak && "break" in record) { return "\n"; } - const inlineCode = readString(record, "inline-code"); + const inlineCode = readStringField(record, "inline-code"); if (inlineCode) { return `\`${inlineCode}\``; } - const code = readString(record, "code"); + const code = readStringField(record, "code"); if (code) { return `\`${code}\``; } - const link = asRecord(record.link); - const linkHref = link ? readString(link, "href") : undefined; + const link = asNullableRecord(record.link); + const linkHref = link ? readStringField(link, "href") : undefined; if (link && linkHref) { - const linkContent = readString(link, "content"); + const linkContent = readStringField(link, "content"); return options?.linkMode === "href" ? linkHref : linkContent || linkHref; } if (Array.isArray(record.bold)) { @@ -332,7 +317,7 @@ export function extractMessageText(content: unknown): string { return content .map((verse) => { - const verseRecord = asRecord(verse); + const verseRecord = asNullableRecord(verse); if (!verseRecord) { return ""; } @@ -351,30 +336,30 @@ export function extractMessageText(content: unknown): string { } // Handle block content (images, code blocks, etc.) - const block = asRecord(verseRecord.block); + const block = asNullableRecord(verseRecord.block); if (block) { - const image = asRecord(block.image); + const image = asNullableRecord(block.image); // Image blocks if (image) { - const imageSrc = readString(image, "src"); + const imageSrc = readStringField(image, "src"); if (imageSrc) { - const altText = readString(image, "alt"); + const altText = readStringField(image, "alt"); const alt = altText ? ` (${altText})` : ""; return `\n${imageSrc}${alt}\n`; } } // Code blocks - const codeBlock = asRecord(block.code); + const codeBlock = asNullableRecord(block.code); if (codeBlock) { - const lang = readString(codeBlock, "lang") ?? ""; - const code = readString(codeBlock, "code") ?? ""; + const lang = readStringField(codeBlock, "lang") ?? ""; + const code = readStringField(codeBlock, "code") ?? ""; return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`; } // Header blocks - const header = asRecord(block.header); + const header = asNullableRecord(block.header); if (header) { const headerContent = Array.isArray(header.content) ? header.content : []; const text = @@ -383,14 +368,14 @@ export function extractMessageText(content: unknown): string { } // Cite/quote blocks - parse the reference structure - const cite = asRecord(block.cite); + const cite = asNullableRecord(block.cite); if (cite) { - const chanCite = asRecord(cite.chan); + const chanCite = asNullableRecord(cite.chan); // ChanCite - reference to a channel message if (chanCite) { - const nest = readString(chanCite, "nest"); - const where = readString(chanCite, "where"); + const nest = readStringField(chanCite, "nest"); + const where = readStringField(chanCite, "where"); // where is typically /msg/~author/timestamp const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/); if (whereMatch) { @@ -401,25 +386,25 @@ export function extractMessageText(content: unknown): string { } // GroupCite - reference to a group - const group = readString(cite, "group"); + const group = readStringField(cite, "group"); if (group) { return `\n> [ref: group ${group}]\n`; } // DeskCite - reference to an app/desk - const desk = asRecord(cite.desk); + const desk = asNullableRecord(cite.desk); if (desk) { - const flag = readString(desk, "flag"); + const flag = readStringField(desk, "flag"); if (flag) { return `\n> [ref: ${flag}]\n`; } } // BaitCite - reference with group+graph context - const bait = asRecord(cite.bait); + const bait = asNullableRecord(cite.bait); if (bait) { - const graph = readString(bait, "graph"); - const groupName = readString(bait, "group"); + const graph = readStringField(bait, "graph"); + const groupName = readStringField(bait, "group"); if (graph && groupName) { return `\n> [ref: ${graph} in ${groupName}]\n`; } diff --git a/extensions/tts-local-cli/speech-provider.ts b/extensions/tts-local-cli/speech-provider.ts index b42bed3a9e94..9568eda73da4 100644 --- a/extensions/tts-local-cli/speech-provider.ts +++ b/extensions/tts-local-cli/speech-provider.ts @@ -14,6 +14,7 @@ import type { SpeechSynthesisRequest, SpeechTelephonySynthesisRequest, } from "openclaw/plugin-sdk/speech-core"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -36,18 +37,12 @@ const DEFAULT_TIMEOUT_MS = 120_000; const MAX_AUDIO_OUTPUT_BYTES = 50 * 1024 * 1024; const MAX_CLI_STDERR_BYTES = 1024 * 1024; -function asObject(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function asStringArray(value: unknown): string[] | undefined { return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : undefined; } -function asRecord(value: unknown): Record | undefined { - const obj = asObject(value); +function readStringRecord(value: unknown): Record | undefined { + const obj = asOptionalRecord(value); if (!obj) { return undefined; } @@ -72,8 +67,8 @@ function normalizeOutputFormat(value: unknown): OutputFormat { } function resolveCliProviderConfig(rawConfig: Record): SpeechProviderConfig { - const providers = asObject(rawConfig.providers); - return asObject(providers?.["tts-local-cli"]) ?? asObject(providers?.cli) ?? {}; + const providers = asOptionalRecord(rawConfig.providers); + return asOptionalRecord(providers?.["tts-local-cli"]) ?? asOptionalRecord(providers?.cli) ?? {}; } function getConfig(cfg: SpeechProviderConfig): CliConfig | null { @@ -87,7 +82,7 @@ function getConfig(cfg: SpeechProviderConfig): CliConfig | null { outputFormat: normalizeOutputFormat(cfg.outputFormat), timeoutMs: typeof cfg.timeoutMs === "number" ? cfg.timeoutMs : DEFAULT_TIMEOUT_MS, cwd: typeof cfg.cwd === "string" ? cfg.cwd : undefined, - env: asRecord(cfg.env), + env: readStringRecord(cfg.env), }; } diff --git a/extensions/vault/vault-secret-ref-resolver.js b/extensions/vault/vault-secret-ref-resolver.js index 1e79aa35e9bc..0e861724a8ab 100644 --- a/extensions/vault/vault-secret-ref-resolver.js +++ b/extensions/vault/vault-secret-ref-resolver.js @@ -317,7 +317,7 @@ async function classifyVaultClientToken(baseUrl, vaultToken) { return "unknown"; } -function readStringField(payload, parsedId) { +function readVaultStringField(payload, parsedId) { const record = payload; const data = resolveKvVersion() === 2 ? record?.data?.data : record?.data; const value = data?.[parsedId.field]; @@ -366,7 +366,7 @@ async function readVaultSecret(baseUrl, vaultToken, id) { } throw new Error(`Vault read failed for "${id}" (${response.status}).`); } - return readStringField(payload, parsedId); + return readVaultStringField(payload, parsedId); } async function resolveFromVault(ids) { diff --git a/extensions/voice-call/doctor-contract-api.ts b/extensions/voice-call/doctor-contract-api.ts index c1e68b142443..7b788f308506 100644 --- a/extensions/voice-call/doctor-contract-api.ts +++ b/extensions/voice-call/doctor-contract-api.ts @@ -12,6 +12,7 @@ import { type PluginDoctorStateMigration, type PluginStateKeyedStore, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { buildVoiceCallLegacyJsonlEventKey, CALL_RECORD_CHUNK_MAX_ENTRIES, @@ -88,12 +89,6 @@ type PluginDoctorStateMigrationParams = Parameters< PluginDoctorStateMigration["detectLegacyState"] >[0]; -function asRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - /** Return Voice Call agents whose templated core session stores need migration. */ export function resolveSessionStoreAgentIds(params: { cfg: OpenClawConfig }): string[] { const agentIds = new Set(); @@ -102,14 +97,14 @@ export function resolveSessionStoreAgentIds(params: { cfg: OpenClawConfig }): st if (!entry) { continue; } - const config = entry.config === undefined ? {} : asRecord(entry.config); + const config = entry.config === undefined ? {} : asOptionalRecord(entry.config); if (!config) { continue; } agentIds.add(normalizeAgentId(typeof config.agentId === "string" ? config.agentId : undefined)); - const numbers = asRecord(config.numbers); + const numbers = asOptionalRecord(config.numbers); for (const route of Object.values(numbers ?? {})) { - const agentId = asRecord(route)?.agentId; + const agentId = asOptionalRecord(route)?.agentId; if (typeof agentId === "string") { agentIds.add(normalizeAgentId(agentId)); } diff --git a/extensions/voice-call/src/config-migration.ts b/extensions/voice-call/src/config-migration.ts index ad24803ea350..19609f7ca567 100644 --- a/extensions/voice-call/src/config-migration.ts +++ b/extensions/voice-call/src/config-migration.ts @@ -1,13 +1,13 @@ // Voice Call setup helper migrates legacy config to the canonical schema. -import { asOptionalRecord, readStringField } from "openclaw/plugin-sdk/string-coerce-runtime"; - -const asObject = asOptionalRecord; -const getString = readStringField; +import { + asFiniteNumber, + asOptionalRecord, + readStringField, +} from "openclaw/plugin-sdk/string-coerce-runtime"; /** Read finite numeric config values. */ function getNumber(obj: Record | undefined, key: string): number | undefined { - const value = obj?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; + return asFiniteNumber(obj?.[key]); } /** Merge legacy provider-specific values into the canonical providers map. */ @@ -17,11 +17,11 @@ function mergeProviderConfig( compatValues: Record, ): Record | undefined { if (Object.keys(compatValues).length === 0) { - return asObject(providersValue); + return asOptionalRecord(providersValue); } - const providers = asObject(providersValue) ?? {}; - const existing = asObject(providers[providerId]) ?? {}; + const providers = asOptionalRecord(providersValue) ?? {}; + const existing = asOptionalRecord(providers[providerId]) ?? {}; return { ...providers, [providerId]: { @@ -39,19 +39,19 @@ export function migrateVoiceCallLegacyConfigInput(params: { config: Record; changes: string[]; } { - const raw = asObject(params.value) ?? {}; - const realtime = asObject(raw.realtime); - const realtimeAgentContext = asObject(realtime?.agentContext); - const twilio = asObject(raw.twilio); - const streaming = asObject(raw.streaming); + const raw = asOptionalRecord(params.value) ?? {}; + const realtime = asOptionalRecord(raw.realtime); + const realtimeAgentContext = asOptionalRecord(realtime?.agentContext); + const twilio = asOptionalRecord(raw.twilio); + const streaming = asOptionalRecord(raw.streaming); const configPathPrefix = params.configPathPrefix ?? "plugins.entries.voice-call.config"; const legacyStreamingOpenAICompat: Record = {}; - const streamingOpenAIApiKey = getString(streaming, "openaiApiKey"); + const streamingOpenAIApiKey = readStringField(streaming, "openaiApiKey"); if (streamingOpenAIApiKey) { legacyStreamingOpenAICompat.apiKey = streamingOpenAIApiKey; } - const streamingSttModel = getString(streaming, "sttModel"); + const streamingSttModel = readStringField(streaming, "sttModel"); if (streamingSttModel) { legacyStreamingOpenAICompat.model = streamingSttModel; } @@ -63,8 +63,8 @@ export function migrateVoiceCallLegacyConfigInput(params: { if (streamingVadThreshold !== undefined) { legacyStreamingOpenAICompat.vadThreshold = streamingVadThreshold; } - const streamingProvider = getString(streaming, "provider"); - const legacyStreamingProvider = getString(streaming, "sttProvider"); + const streamingProvider = readStringField(streaming, "provider"); + const legacyStreamingProvider = readStringField(streaming, "sttProvider"); const normalizedStreaming: Record | undefined = streaming ? { diff --git a/extensions/voice-call/src/realtime-agent-context.ts b/extensions/voice-call/src/realtime-agent-context.ts index 6defdfc7cc3c..774bcab1af8d 100644 --- a/extensions/voice-call/src/realtime-agent-context.ts +++ b/extensions/voice-call/src/realtime-agent-context.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { buildRealtimeVoiceAgentConsultPolicyInstructions } from "openclaw/plugin-sdk/realtime-voice"; import { root } from "openclaw/plugin-sdk/security-runtime"; -import { normalizeOptionalString as normalizeString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import type { VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps } from "./core-bridge.js"; @@ -88,12 +88,20 @@ export async function buildRealtimeVoiceInstructions(params: { | VoiceIdentityLike | undefined; const identityLines = [ - normalizeString(identity?.name) ? `- Name: ${normalizeString(identity?.name)}` : undefined, - normalizeString(identity?.emoji) ? `- Emoji: ${normalizeString(identity?.emoji)}` : undefined, - normalizeString(identity?.vibe) ? `- Vibe: ${normalizeString(identity?.vibe)}` : undefined, - normalizeString(identity?.theme) ? `- Theme: ${normalizeString(identity?.theme)}` : undefined, - normalizeString(identity?.creature) - ? `- Creature/persona: ${normalizeString(identity?.creature)}` + normalizeOptionalString(identity?.name) + ? `- Name: ${normalizeOptionalString(identity?.name)}` + : undefined, + normalizeOptionalString(identity?.emoji) + ? `- Emoji: ${normalizeOptionalString(identity?.emoji)}` + : undefined, + normalizeOptionalString(identity?.vibe) + ? `- Vibe: ${normalizeOptionalString(identity?.vibe)}` + : undefined, + normalizeOptionalString(identity?.theme) + ? `- Theme: ${normalizeOptionalString(identity?.theme)}` + : undefined, + normalizeOptionalString(identity?.creature) + ? `- Creature/persona: ${normalizeOptionalString(identity?.creature)}` : undefined, ].filter(Boolean); if (identityLines.length > 0) { diff --git a/extensions/volcengine/speech-provider.ts b/extensions/volcengine/speech-provider.ts index 6b5ad51f8047..eb5c4c10e30a 100644 --- a/extensions/volcengine/speech-provider.ts +++ b/extensions/volcengine/speech-provider.ts @@ -7,12 +7,11 @@ import type { SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; import { - asObject, parseSpeechDirectiveNumberOverride, resolveSpeechProviderApiKey, trimToUndefined, } from "openclaw/plugin-sdk/speech-core"; -import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { asFiniteNumberInRange, asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { volcengineTTS, type VolcengineTtsEncoding } from "./tts.js"; const DEFAULT_VOICE = "en_female_anna_mars_bigtts"; @@ -59,8 +58,8 @@ function normalizeSpeedRatio(value: unknown): number | undefined { function normalizeVolcengineProviderConfig( rawConfig: Record, ): VolcengineTtsProviderConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.volcengine) ?? asObject(rawConfig.volcengine); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.volcengine) ?? asOptionalRecord(rawConfig.volcengine); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, diff --git a/extensions/vydra/shared.ts b/extensions/vydra/shared.ts index 20b7f26a5ada..b740bf624e61 100644 --- a/extensions/vydra/shared.ts +++ b/extensions/vydra/shared.ts @@ -16,6 +16,7 @@ import { import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; import { + asOptionalRecord, normalizeOptionalLowercaseString, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -48,17 +49,11 @@ type VydraJobPayload = { error?: string | { message?: string; detail?: string } | null; }; -function asObject(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - function addUrlValue(value: unknown, urls: Set): void { - if (typeof value === "string") { - const trimmed = value.trim(); - if (/^https?:\/\//iu.test(trimmed)) { - urls.add(trimmed); + const normalized = normalizeOptionalString(value); + if (normalized !== undefined) { + if (/^https?:\/\//iu.test(normalized)) { + urls.add(normalized); } return; } @@ -69,11 +64,9 @@ function addUrlValue(value: unknown, urls: Set): void { } } -export const trimToUndefined = normalizeOptionalString; - export function normalizeVydraBaseUrl(value: string | undefined): string { const fallback = DEFAULT_VYDRA_BASE_URL; - const trimmed = trimToUndefined(value); + const trimmed = normalizeOptionalString(value); if (!trimmed) { return fallback; } @@ -95,10 +88,10 @@ export function normalizeVydraBaseUrl(value: string | undefined): string { } function resolveVydraBaseUrlFromConfig(cfg: unknown): string { - const models = asObject(asObject(cfg)?.models); - const providers = asObject(models?.providers); - const vydra = asObject(providers?.vydra); - return normalizeVydraBaseUrl(trimToUndefined(vydra?.baseUrl)); + const models = asOptionalRecord(asOptionalRecord(cfg)?.models); + const providers = asOptionalRecord(models?.providers); + const vydra = asOptionalRecord(providers?.vydra); + return normalizeVydraBaseUrl(normalizeOptionalString(vydra?.baseUrl)); } export async function resolveVydraRequestContext(params: { @@ -150,25 +143,27 @@ export async function resolveVydraRequestContext(params: { } export function resolveVydraResponseJobId(payload: unknown): string | undefined { - const object = asObject(payload) as VydraJobPayload | undefined; - return trimToUndefined(object?.jobId) ?? trimToUndefined(object?.id); + const object = asOptionalRecord(payload) as VydraJobPayload | undefined; + return normalizeOptionalString(object?.jobId) ?? normalizeOptionalString(object?.id); } export function resolveVydraResponseStatus(payload: unknown): string | undefined { - return normalizeOptionalLowercaseString(trimToUndefined(asObject(payload)?.status)); + return normalizeOptionalLowercaseString( + normalizeOptionalString(asOptionalRecord(payload)?.status), + ); } function resolveVydraErrorMessage(payload: unknown): string | undefined { - const object = asObject(payload) as VydraJobPayload | undefined; + const object = asOptionalRecord(payload) as VydraJobPayload | undefined; const error = object?.error; if (typeof error === "string" && error.trim()) { return error.trim(); } - const errorObject = asObject(error); + const errorObject = asOptionalRecord(error); return ( - trimToUndefined(errorObject?.message) ?? - trimToUndefined(errorObject?.detail) ?? - trimToUndefined(object?.message) + normalizeOptionalString(errorObject?.message) ?? + normalizeOptionalString(errorObject?.detail) ?? + normalizeOptionalString(object?.message) ); } @@ -193,7 +188,7 @@ export function extractVydraResultUrls(payload: unknown, kind: VydraMediaKind): } return; } - const object = asObject(value); + const object = asOptionalRecord(value); if (!object) { return; } diff --git a/extensions/vydra/speech-provider.ts b/extensions/vydra/speech-provider.ts index f14707e0764a..d65f25eaf1f1 100644 --- a/extensions/vydra/speech-provider.ts +++ b/extensions/vydra/speech-provider.ts @@ -12,7 +12,11 @@ import type { SpeechProviderOverrides, SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; -import { asObject, resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core"; +import { resolveSpeechProviderApiKey } from "openclaw/plugin-sdk/speech-core"; +import { + asOptionalRecord, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { DEFAULT_VYDRA_BASE_URL, DEFAULT_VYDRA_SPEECH_MODEL, @@ -20,7 +24,6 @@ import { downloadVydraAsset, extractVydraResultUrls, normalizeVydraBaseUrl, - trimToUndefined, } from "./shared.js"; type VydraSpeechConfig = { @@ -38,23 +41,23 @@ const VYDRA_SPEECH_VOICES = [ ] as const; function normalizeVydraSpeechConfig(rawConfig: Record): VydraSpeechConfig { - const providers = asObject(rawConfig.providers); - const raw = asObject(providers?.vydra) ?? asObject(rawConfig.vydra); + const providers = asOptionalRecord(rawConfig.providers); + const raw = asOptionalRecord(providers?.vydra) ?? asOptionalRecord(rawConfig.vydra); return { apiKey: normalizeResolvedSecretInputString({ value: raw?.apiKey, path: "tts.providers.vydra.apiKey", }), baseUrl: normalizeVydraBaseUrl( - trimToUndefined(raw?.baseUrl) ?? trimToUndefined(process.env.VYDRA_BASE_URL), + normalizeOptionalString(raw?.baseUrl) ?? normalizeOptionalString(process.env.VYDRA_BASE_URL), ), model: - trimToUndefined(raw?.model) ?? - trimToUndefined(process.env.VYDRA_TTS_MODEL) ?? + normalizeOptionalString(raw?.model) ?? + normalizeOptionalString(process.env.VYDRA_TTS_MODEL) ?? DEFAULT_VYDRA_SPEECH_MODEL, voiceId: - trimToUndefined(raw?.voiceId) ?? - trimToUndefined(process.env.VYDRA_TTS_VOICE_ID) ?? + normalizeOptionalString(raw?.voiceId) ?? + normalizeOptionalString(process.env.VYDRA_TTS_VOICE_ID) ?? DEFAULT_VYDRA_VOICE_ID, }; } @@ -62,10 +65,10 @@ function normalizeVydraSpeechConfig(rawConfig: Record): VydraSp function readVydraSpeechConfig(config: SpeechProviderConfig): VydraSpeechConfig { const normalized = normalizeVydraSpeechConfig({}); return { - apiKey: trimToUndefined(config.apiKey) ?? normalized.apiKey, - baseUrl: normalizeVydraBaseUrl(trimToUndefined(config.baseUrl) ?? normalized.baseUrl), - model: trimToUndefined(config.model) ?? normalized.model, - voiceId: trimToUndefined(config.voiceId) ?? normalized.voiceId, + apiKey: normalizeOptionalString(config.apiKey) ?? normalized.apiKey, + baseUrl: normalizeVydraBaseUrl(normalizeOptionalString(config.baseUrl) ?? normalized.baseUrl), + model: normalizeOptionalString(config.model) ?? normalized.model, + voiceId: normalizeOptionalString(config.voiceId) ?? normalized.voiceId, }; } @@ -77,8 +80,8 @@ function readVydraOverrides(overrides: SpeechProviderOverrides | undefined): { return {}; } return { - model: trimToUndefined(overrides.model), - voiceId: trimToUndefined(overrides.voiceId), + model: normalizeOptionalString(overrides.model), + voiceId: normalizeOptionalString(overrides.voiceId), }; } diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts index eb5227b90991..1f43e0247f70 100644 --- a/extensions/whatsapp/src/agent-tools-login.ts +++ b/extensions/whatsapp/src/agent-tools-login.ts @@ -4,13 +4,14 @@ import { readPositiveIntegerParam, } from "openclaw/plugin-sdk/channel-actions"; import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract"; +import { hasNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { Type } from "typebox"; import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js"; const QR_DATA_URL_MAX_LENGTH = 16_384; -function readOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value : undefined; +function readLoginStringPreservingWhitespace(value: unknown): string | undefined { + return hasNonEmptyString(value) ? value : undefined; } export function createWhatsAppLoginTool(): ChannelAgentTool { @@ -55,13 +56,15 @@ export function createWhatsAppLoginTool(): ChannelAgentTool { }; const action = (args as { action?: string })?.action ?? "start"; - const accountId = readOptionalString((args as { accountId?: unknown }).accountId); + const accountId = readLoginStringPreservingWhitespace( + (args as { accountId?: unknown }).accountId, + ); const timeoutMs = readPositiveIntegerParam(args as Record, "timeoutMs"); if (action === "wait") { const result = await waitForWebLogin({ accountId, timeoutMs, - currentQrDataUrl: readOptionalString( + currentQrDataUrl: readLoginStringPreservingWhitespace( (args as { currentQrDataUrl?: unknown }).currentQrDataUrl, ), }); diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 7a6411ed377e..b4a55848348e 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { escapeRegExp, formatEnvelopeTimestamp } from "openclaw/plugin-sdk/channel-test-helpers"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { toErrorObject } from "openclaw/plugin-sdk/error-runtime"; import { getChildLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { getActiveWebListener } from "./active-listener.js"; @@ -252,7 +253,7 @@ describe("web auto-reply connection", () => { }, }; const listenerFactory = vi.fn(async () => { - throw toLintErrorObject(boom428, "Non-Error thrown"); + throw toErrorObject(boom428, "Non-Error thrown"); }); const sleep = vi.fn(async () => {}); @@ -1068,17 +1069,3 @@ describe("web auto-reply connection", () => { expect(content).toMatch(/auto/); }); }); - -function toLintErrorObject(value: unknown, fallbackMessage: string): Error { - if (value instanceof Error) { - return value; - } - if (typeof value === "string") { - return new Error(value); - } - const error = new Error(fallbackMessage, { cause: value }); - if ((typeof value === "object" && value !== null) || typeof value === "function") { - Object.assign(error, value); - } - return error; -} diff --git a/extensions/whatsapp/src/status-issues.ts b/extensions/whatsapp/src/status-issues.ts index f6a2abc8d707..1046f0235182 100644 --- a/extensions/whatsapp/src/status-issues.ts +++ b/extensions/whatsapp/src/status-issues.ts @@ -5,11 +5,11 @@ import type { } from "openclaw/plugin-sdk/channel-contract"; import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime"; import { - asString, collectIssuesForEnabledAccounts, isRecord, readAccountStatusSnapshot, } from "openclaw/plugin-sdk/status-helpers"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const WHATSAPP_ACCOUNT_STATUS_FIELDS = [ "statusState", @@ -25,7 +25,7 @@ const RECENT_DISCONNECT_WARNING_WINDOW_MS = 15 * 60 * 1000; function readLastDisconnect(value: unknown): { at: number | null; error?: string } | null { if (typeof value === "string") { - const error = asString(value); + const error = normalizeOptionalString(value); return error ? { at: null, error } : null; } if (!isRecord(value)) { @@ -33,7 +33,7 @@ function readLastDisconnect(value: unknown): { at: number | null; error?: string } return { at: typeof value.at === "number" ? value.at : null, - error: asString(value.error), + error: normalizeOptionalString(value.error), }; } @@ -52,7 +52,7 @@ export function collectWhatsAppStatusIssues( readAccount: (value) => readAccountStatusSnapshot(value, WHATSAPP_ACCOUNT_STATUS_FIELDS), collectIssues: ({ account, accountId, issues }) => { const linked = account.linked === true; - const statusState = asString(account.statusState); + const statusState = normalizeOptionalString(account.statusState); const running = account.running === true; const connected = account.connected === true; const reconnectAttempts = @@ -60,8 +60,8 @@ export function collectWhatsAppStatusIssues( const lastInboundAt = typeof account.lastInboundAt === "number" ? account.lastInboundAt : null; const lastDisconnect = readLastDisconnect(account.lastDisconnect); - const lastError = asString(account.lastError) ?? lastDisconnect?.error; - const healthState = asString(account.healthState); + const lastError = normalizeOptionalString(account.lastError) ?? lastDisconnect?.error; + const healthState = normalizeOptionalString(account.healthState); if (statusState === "unstable") { issues.push({ diff --git a/extensions/workboard/src/store-card-helpers.ts b/extensions/workboard/src/store-card-helpers.ts index 30710d2d012a..55d2b07bfd5f 100644 --- a/extensions/workboard/src/store-card-helpers.ts +++ b/extensions/workboard/src/store-card-helpers.ts @@ -15,6 +15,7 @@ import { type WorkboardStatus, } from "@openclaw/workboard-contract"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { BLOCKED_TOO_LONG_MS, @@ -27,7 +28,6 @@ import type { WorkboardMutationScope } from "./store-inputs.js"; import { metadataIsEmpty, normalizeEvents, - normalizeOptionalString, normalizeTimestamp, removeUndefinedMetadataFields, } from "./store-normalizers.js"; diff --git a/extensions/workboard/src/store-core.ts b/extensions/workboard/src/store-core.ts index bbff7ea07299..990c0399557f 100644 --- a/extensions/workboard/src/store-core.ts +++ b/extensions/workboard/src/store-core.ts @@ -7,6 +7,7 @@ import type { WorkboardMetadata, WorkboardStatus, } from "@openclaw/workboard-contract"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { PersistedWorkboardAttachment, PersistedWorkboardBoard, @@ -55,7 +56,6 @@ import { normalizeLinkType, normalizeMetadata, normalizeNotes, - normalizeOptionalString, normalizePosition, normalizePriority, normalizeStatus, diff --git a/extensions/workboard/src/store-normalizers.ts b/extensions/workboard/src/store-normalizers.ts index 5627dbb68405..55e23f7f237e 100644 --- a/extensions/workboard/src/store-normalizers.ts +++ b/extensions/workboard/src/store-normalizers.ts @@ -46,6 +46,7 @@ import { type WorkboardWorkerProtocol, type WorkboardWorkspace, } from "@openclaw/workboard-contract"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { MAX_ATTACHMENT_BYTES, MAX_CARD_ARTIFACTS, @@ -68,10 +69,6 @@ import type { } from "./store-inputs.js"; import { isAbsoluteWorkspacePath } from "./workspace-path.js"; -export function normalizeOptionalString(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - export function normalizeBoardId(value: unknown, fallback?: string): string | undefined { const raw = normalizeBoundedString(value, fallback, 80, "board id"); if (!raw) { diff --git a/extensions/workboard/src/store-workflow.ts b/extensions/workboard/src/store-workflow.ts index d96a42d76d0d..52fe353871c3 100644 --- a/extensions/workboard/src/store-workflow.ts +++ b/extensions/workboard/src/store-workflow.ts @@ -9,6 +9,7 @@ import type { } from "@openclaw/workboard-contract"; import { isFutureDateTimestampMs } from "openclaw/plugin-sdk/number-runtime"; import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { appendEvent, assertCanMutateClaimedCard, @@ -51,7 +52,6 @@ import { normalizeArtifact, normalizeAutomation, normalizeBoundedString, - normalizeOptionalString, normalizeProofInput, normalizeStatus, normalizeStringList, diff --git a/extensions/workboard/src/workspace-access.ts b/extensions/workboard/src/workspace-access.ts index 6f014fc48659..7d3cf8d8f95d 100644 --- a/extensions/workboard/src/workspace-access.ts +++ b/extensions/workboard/src/workspace-access.ts @@ -15,6 +15,7 @@ import { canonicalPathFromExistingAncestor, isPathInside, } from "openclaw/plugin-sdk/security-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export type { WorkboardWorkspaceAccess } from "@openclaw/workboard-contract"; @@ -302,14 +303,8 @@ async function assertWorkspaceAllowed( return undefined; } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - export function containsWorkboardWorkspaceMutation(value: unknown): boolean { - const record = readRecord(value); + const record = asOptionalRecord(value); if (!record) { return false; } @@ -318,7 +313,7 @@ export function containsWorkboardWorkspaceMutation(value: unknown): boolean { } return ( containsWorkboardWorkspaceMutation(record.patch) || - containsWorkboardWorkspaceMutation(readRecord(record.metadata)?.automation) || + containsWorkboardWorkspaceMutation(asOptionalRecord(record.metadata)?.automation) || (Array.isArray(record.children) && record.children.some((child) => containsWorkboardWorkspaceMutation(child))) ); @@ -332,7 +327,7 @@ export function withWorkboardWorkspaceAccess( } export function withoutWorkboardWorkspaceAccess(value: unknown): Record { - const record = readRecord(value) ?? {}; + const record = asOptionalRecord(value) ?? {}; const { workspaceAccess: _untrustedWorkspaceAccess, ...rest } = record; return rest; } @@ -359,7 +354,7 @@ export async function assertWorkboardWorkspaceMutationAccess( if (access.unrestricted) { return; } - const record = readRecord(value); + const record = asOptionalRecord(value); if (!record) { return; } @@ -368,12 +363,12 @@ export async function assertWorkboardWorkspaceMutationAccess( await assertWorkspaceAllowed(record.workspace, access); await assertWorkspaceAllowed(record.defaultWorkspace, access); - const patch = readRecord(record.patch); + const patch = asOptionalRecord(record.patch); if (patch) { await assertWorkboardWorkspaceMutationAccess(patch, access); } - const metadata = readRecord(record.metadata); - const automation = readRecord(metadata?.automation); + const metadata = asOptionalRecord(record.metadata); + const automation = asOptionalRecord(metadata?.automation); if (automation) { await assertWorkboardWorkspaceMutationAccess(automation, access); } diff --git a/extensions/xai/capability-provider-metadata.ts b/extensions/xai/capability-provider-metadata.ts index 0b8799cc1441..903e7966f03c 100644 --- a/extensions/xai/capability-provider-metadata.ts +++ b/extensions/xai/capability-provider-metadata.ts @@ -17,8 +17,8 @@ import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-i import { isRecord, normalizeOptionalString, - parseBooleanValue as readBoolean, - parseFiniteNumber as readFiniteNumber, + parseBooleanValue, + parseFiniteNumber, } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { VideoGenerationProvider, @@ -210,10 +210,10 @@ export function normalizeXaiRealtimeTranscriptionProviderConfig( path: "plugins.entries.voice-call.config.streaming.providers.xai.apiKey", }), baseUrl: normalizeOptionalString(xai.baseUrl), - sampleRate: readFiniteNumber(xai.sampleRate ?? xai.sample_rate), + sampleRate: parseFiniteNumber(xai.sampleRate ?? xai.sample_rate), encoding: normalizeRealtimeTranscriptionEncoding(xai.encoding), - interimResults: readBoolean(xai.interimResults ?? xai.interim_results), - endpointingMs: readFiniteNumber(xai.endpointingMs ?? xai.endpointing ?? xai.silenceDurationMs), + interimResults: parseBooleanValue(xai.interimResults ?? xai.interim_results), + endpointingMs: parseFiniteNumber(xai.endpointingMs ?? xai.endpointing ?? xai.silenceDurationMs), language: normalizeOptionalString(xai.language), }; } diff --git a/extensions/xai/realtime-voice-config.ts b/extensions/xai/realtime-voice-config.ts index a3b23d056a8a..7b7d12093eb3 100644 --- a/extensions/xai/realtime-voice-config.ts +++ b/extensions/xai/realtime-voice-config.ts @@ -9,8 +9,9 @@ import type { import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input"; import { asFiniteNumber, + asOptionalObjectRecord, normalizeOptionalString, - parseBooleanValue as readBoolean, + parseBooleanValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { XAI_BASE_URL } from "./model-definitions.js"; @@ -144,14 +145,10 @@ export function serializeXaiRealtimeToolResult(result: unknown): string { throw new Error(message); } -function readRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function readNestedXaiConfig(rawConfig: RealtimeVoiceProviderConfig) { - const raw = readRecord(rawConfig); - const providers = readRecord(raw?.providers); - return readRecord(providers?.xai ?? raw?.xai ?? raw) ?? {}; + const raw = asOptionalObjectRecord(rawConfig); + const providers = asOptionalObjectRecord(raw?.providers); + return asOptionalObjectRecord(providers?.xai ?? raw?.xai ?? raw) ?? {}; } export function normalizeXaiRealtimeBaseUrl(value?: string): string { @@ -207,9 +204,9 @@ export function normalizeXaiRealtimeProviderConfig( vadThreshold: asXaiVadThreshold(raw.vadThreshold), silenceDurationMs: asXaiDurationMs(raw.silenceDurationMs), prefixPaddingMs: asXaiDurationMs(raw.prefixPaddingMs), - interruptResponseOnInputAudio: readBoolean(raw.interruptResponseOnInputAudio), + interruptResponseOnInputAudio: parseBooleanValue(raw.interruptResponseOnInputAudio), reasoningEffort: asXaiReasoningEffort(raw.reasoningEffort), - sessionResumption: readBoolean(raw.sessionResumption), + sessionResumption: parseBooleanValue(raw.sessionResumption), }; } @@ -217,7 +214,7 @@ export function readXaiRealtimeErrorDetail(error: unknown): string { if (typeof error === "string" && error) { return error; } - const record = readRecord(error); + const record = asOptionalObjectRecord(error); return ( normalizeOptionalString(record?.message) ?? normalizeOptionalString(record?.code) ?? diff --git a/extensions/xai/setup-api.ts b/extensions/xai/setup-api.ts index 697f03a21071..aa12e8d181a9 100644 --- a/extensions/xai/setup-api.ts +++ b/extensions/xai/setup-api.ts @@ -1,6 +1,6 @@ // Xai API module exposes the plugin public contract. import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { isRecord } from "./src/tool-config-shared.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export default definePluginEntry({ id: "xai", diff --git a/extensions/xai/src/tool-config-shared.ts b/extensions/xai/src/tool-config-shared.ts index 3194c9cedb50..3602affd969a 100644 --- a/extensions/xai/src/tool-config-shared.ts +++ b/extensions/xai/src/tool-config-shared.ts @@ -2,8 +2,6 @@ import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeXaiModelId } from "../model-id.js"; -export { isRecord }; - export function coerceXaiToolConfig( config: Record | undefined, ): Record { diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts index 60e188fa334d..314ccbaf6e77 100644 --- a/extensions/xai/src/web-search-shared.ts +++ b/extensions/xai/src/web-search-shared.ts @@ -1,6 +1,7 @@ // Xai plugin module implements web search shared behavior. import { readProviderJsonObjectResponse } from "openclaw/plugin-sdk/provider-http"; import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { XAI_DEFAULT_MODEL_ID } from "../model-definitions.js"; import { normalizeXaiModelId } from "../model-id.js"; import { @@ -8,7 +9,6 @@ import { requireXaiResponseTextCitationsAndInline, resolveXaiResponsesEndpoint, } from "./responses-tool-shared.js"; -import { isRecord } from "./tool-config-shared.js"; import type { XaiWebSearchResponse } from "./web-search-response.types.js"; export { extractXaiWebSearchContent } from "./responses-tool-shared.js"; export type { XaiWebSearchResponse } from "./web-search-response.types.js"; diff --git a/extensions/xai/src/x-search-config.ts b/extensions/xai/src/x-search-config.ts index 62e3c318ceb6..662f0c526d77 100644 --- a/extensions/xai/src/x-search-config.ts +++ b/extensions/xai/src/x-search-config.ts @@ -1,6 +1,6 @@ // Xai helper module supports x search config behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { isRecord } from "./tool-config-shared.js"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; type JsonRecord = Record; diff --git a/extensions/xai/src/xai-user-agent.ts b/extensions/xai/src/xai-user-agent.ts index d3934fffa352..36aa3c5be2a1 100644 --- a/extensions/xai/src/xai-user-agent.ts +++ b/extensions/xai/src/xai-user-agent.ts @@ -1,27 +1,23 @@ // Shared User-Agent for xAI sidecar HTTP/WS requests; mirrors `formatOpenClawUserAgent`. import { OPENCLAW_VERSION as PACKAGE_VERSION } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; const ORIGINATOR = "openclaw"; const UNUSABLE_PACKAGE_VERSION = "0.0.0"; const FALLBACK_VERSION = "unknown"; -function trimToUndefined(value: string | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : undefined; -} - function resolveXaiUserAgentVersion(): string { // Env-first matches resolveRuntimeServiceVersion. - const envVersion = trimToUndefined(process.env.OPENCLAW_VERSION); + const envVersion = normalizeOptionalString(process.env.OPENCLAW_VERSION); if (envVersion) { return envVersion; } - const packageVersion = trimToUndefined(PACKAGE_VERSION); + const packageVersion = normalizeOptionalString(PACKAGE_VERSION); if (packageVersion && packageVersion !== UNUSABLE_PACKAGE_VERSION) { return packageVersion; } - return trimToUndefined(process.env.npm_package_version) ?? FALLBACK_VERSION; + return normalizeOptionalString(process.env.npm_package_version) ?? FALLBACK_VERSION; } export function xaiUserAgent(): string { diff --git a/extensions/xai/tts.ts b/extensions/xai/tts.ts index 1c2d55c3dcd1..cb19d3b76ce0 100644 --- a/extensions/xai/tts.ts +++ b/extensions/xai/tts.ts @@ -6,11 +6,12 @@ import { readProviderBinaryResponse, readProviderJsonResponse, } from "openclaw/plugin-sdk/provider-http"; -import { asObject, trimToUndefined, type SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; +import { trimToUndefined, type SpeechVoiceOption } from "openclaw/plugin-sdk/speech"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress"; import WebSocket from "ws"; import { XAI_BASE_URL } from "./model-definitions.js"; @@ -48,12 +49,12 @@ export async function listXaiTtsVoices(params: { const payload = await readProviderJsonResponse(response, "xAI TTS voices", { maxBytes: XAI_TTS_VOICE_LIST_MAX_BYTES, }); - const voices = asObject(payload)?.voices; + const voices = asOptionalRecord(payload)?.voices; if (!Array.isArray(voices)) { throw new Error("xAI TTS voices: malformed JSON response"); } return voices.flatMap((value) => { - const voice = asObject(value); + const voice = asOptionalRecord(value); const id = trimToUndefined(voice?.voice_id); if (!id) { return []; diff --git a/extensions/xiaomi/speech-provider.ts b/extensions/xiaomi/speech-provider.ts index 811836659192..7e054b052b28 100644 --- a/extensions/xiaomi/speech-provider.ts +++ b/extensions/xiaomi/speech-provider.ts @@ -12,15 +12,12 @@ import type { SpeechProviderOverrides, SpeechProviderPlugin, } from "openclaw/plugin-sdk/speech-core"; -import { - asObject, - resolveSpeechProviderApiKey, - trimToUndefined, -} from "openclaw/plugin-sdk/speech-core"; +import { resolveSpeechProviderApiKey, trimToUndefined } from "openclaw/plugin-sdk/speech-core"; import { fetchWithSsrFGuard, ssrfPolicyFromHttpBaseUrlAllowedHostname, } from "openclaw/plugin-sdk/ssrf-runtime"; +import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; const DEFAULT_XIAOMI_TTS_BASE_URL = "https://api.xiaomimimo.com/v1"; const DEFAULT_XIAOMI_TTS_MODEL = "mimo-v2.5-tts"; @@ -76,8 +73,12 @@ function normalizeXiaomiTtsFormat(value: unknown): XiaomiTtsFormat | undefined { function resolveXiaomiTtsConfigRecord( rawConfig: Record, ): Record | undefined { - const providers = asObject(rawConfig.providers); - return asObject(providers?.xiaomi) ?? asObject(providers?.mimo) ?? asObject(rawConfig.xiaomi); + const providers = asOptionalRecord(rawConfig.providers); + return ( + asOptionalRecord(providers?.xiaomi) ?? + asOptionalRecord(providers?.mimo) ?? + asOptionalRecord(rawConfig.xiaomi) + ); } function normalizeXiaomiTtsProviderConfig( @@ -236,11 +237,11 @@ function buildXiaomiTtsAudio(params: { model: string; voice: string; format: Xia } function decodeXiaomiAudioData(body: unknown): Buffer { - const root = asObject(body); + const root = asOptionalRecord(body); const choices = Array.isArray(root?.choices) ? root.choices : []; - const firstChoice = asObject(choices[0]); - const message = asObject(firstChoice?.message); - const audio = asObject(message?.audio); + const firstChoice = asOptionalRecord(choices[0]); + const message = asOptionalRecord(firstChoice?.message); + const audio = asOptionalRecord(message?.audio); const audioData = trimToUndefined(audio?.data); if (!audioData) { throw new Error("Xiaomi TTS API returned no audio data"); diff --git a/package.json b/package.json index 8246491dc76d..c8b460973946 100644 --- a/package.json +++ b/package.json @@ -1535,6 +1535,7 @@ "check:bundled-channel-config-metadata": "node --import tsx scripts/generate-bundled-channel-config-metadata.ts --check", "check:changed": "node scripts/check-changed.mjs", "check:changelog-attributions": "node --import tsx scripts/check-changelog-attributions.mts", + "check:coercion-helpers": "node --import tsx scripts/check-coercion-helper-declarations.mts", "check:database-first-legacy-stores": "node --import tsx scripts/check-database-first-legacy-stores.mts", "check:deprecated-api-usage": "node --import tsx scripts/check-deprecated-api-usage.mts", "check:deprecated-jsdoc": "node --import tsx scripts/check-deprecated-jsdoc.mts", diff --git a/packages/acp-core/src/meta.test.ts b/packages/acp-core/src/meta.test.ts index 2cd4b057dc36..83ffbb60854a 100644 --- a/packages/acp-core/src/meta.test.ts +++ b/packages/acp-core/src/meta.test.ts @@ -1,10 +1,17 @@ // ACP Core tests cover meta behavior. import { describe, expect, it } from "vitest"; -import { readBool, readNonNegativeInteger, readNumber, readString } from "./meta.js"; +import { + readBool, + readMetadataNumber, + readMetadataString, + readNonNegativeInteger, +} from "./meta.js"; describe("ACP metadata readers", () => { it("returns the first normalized string value", () => { - expect(readString({ old: " ", current: " session-1 " }, ["old", "current"])).toBe("session-1"); + expect(readMetadataString({ old: " ", current: " session-1 " }, ["old", "current"])).toBe( + "session-1", + ); }); it("preserves false boolean values", () => { @@ -12,8 +19,8 @@ describe("ACP metadata readers", () => { }); it("accepts finite numbers and rejects non-numeric values", () => { - expect(readNumber({ first: "1", second: 0 }, ["first", "second"])).toBe(0); - expect(readNumber({ first: Number.POSITIVE_INFINITY }, ["first"])).toBeUndefined(); + expect(readMetadataNumber({ first: "1", second: 0 }, ["first", "second"])).toBe(0); + expect(readMetadataNumber({ first: Number.POSITIVE_INFINITY }, ["first"])).toBeUndefined(); }); it("accepts zero as a non-negative integer", () => { diff --git a/packages/acp-core/src/meta.ts b/packages/acp-core/src/meta.ts index b295ce7f0e1b..4ce22b1478d1 100644 --- a/packages/acp-core/src/meta.ts +++ b/packages/acp-core/src/meta.ts @@ -19,7 +19,7 @@ function readMetaValue( } /** Reads the first present string metadata value from a current-to-legacy key list. */ -export function readString( +export function readMetadataString( meta: Record | null | undefined, keys: string[], ): string | undefined { @@ -35,7 +35,7 @@ export function readBool( } /** Reads the first finite numeric metadata value from a current-to-legacy key list. */ -export function readNumber( +export function readMetadataNumber( meta: Record | null | undefined, keys: string[], ): number | undefined { diff --git a/packages/agent-core/src/harness/messages.test.ts b/packages/agent-core/src/harness/messages.test.ts index e264f8359d41..7926242ec3c9 100644 --- a/packages/agent-core/src/harness/messages.test.ts +++ b/packages/agent-core/src/harness/messages.test.ts @@ -24,6 +24,22 @@ describe("harness message timestamps", () => { expect(message?.timestamp).toBe(Date.parse(timestamp)); }); + it.each(["0", "2026"])( + "preserves Date.parse semantics for numeric-looking persisted timestamp %s", + (timestamp) => { + const [message] = convertToLlm([ + { + role: "compactionSummary", + summary: "older context", + tokensBefore: 123, + timestamp, + }, + ]); + + expect(message?.timestamp).toBe(Date.parse(timestamp)); + }, + ); + it("keeps corrupt persisted compaction timestamps non-fatal", () => { const persistedMessages: Parameters[0] = [ { diff --git a/packages/ai/src/providers/google-shared.convert.test.ts b/packages/ai/src/providers/google-shared.convert.test.ts index 0cff95265318..764fa6b50e3f 100644 --- a/packages/ai/src/providers/google-shared.convert.test.ts +++ b/packages/ai/src/providers/google-shared.convert.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import type { Context, Tool } from "../types.js"; import { convertMessages, convertTools } from "./google-shared.js"; import { - asRecord, + assertRecord, expectConvertedRoles, getFirstToolParameters, makeGeminiCliAssistantMessage, @@ -107,11 +107,11 @@ describe("google-shared convertTools", () => { const params = getFirstToolParameters( converted as Parameters[0], ); - const properties = asRecord(params.properties); - const mode = asRecord(properties.mode); - const options = asRecord(properties.options); - const list = asRecord(properties.list); - const items = asRecord(list.items); + const properties = assertRecord(params.properties); + const mode = assertRecord(properties.mode); + const options = assertRecord(properties.options); + const list = assertRecord(properties.list); + const items = assertRecord(list.items); expect(params.patternProperties).toEqual({ "^x-": { type: "string" } }); expect(params.additionalProperties).toBe(false); @@ -150,11 +150,11 @@ describe("google-shared convertTools", () => { const params = getFirstToolParameters( converted as Parameters[0], ); - const config = asRecord(asRecord(params.properties).config); - const configProps = asRecord(config.properties); - const retries = asRecord(configProps.retries); - const tags = asRecord(configProps.tags); - const items = asRecord(tags.items); + const config = assertRecord(assertRecord(params.properties).config); + const configProps = assertRecord(config.properties); + const retries = assertRecord(configProps.retries); + const tags = assertRecord(configProps.tags); + const items = assertRecord(tags.items); expect(params.type).toBe("object"); expect(config.type).toBe("object"); @@ -456,7 +456,7 @@ describe("google-shared convertMessages", () => { const contents = convertMessagesForTest(model, context); expect(contents).toHaveLength(1); expect(expectDefined(contents[0], "contents[0] test invariant").role).toBe("model"); - const part = asRecord(expectDefined(contents[0], "contents[0] test invariant").parts?.[0]); + const part = assertRecord(expectDefined(contents[0], "contents[0] test invariant").parts?.[0]); expect(part.thought).toBe(true); expect(part.thoughtSignature).toBe("c2ln"); }); @@ -478,7 +478,7 @@ describe("google-shared convertMessages", () => { const contents = convertMessagesForTest(model, context); const parts = contents?.[0]?.parts ?? []; expect(parts).toHaveLength(1); - const part = asRecord(parts[0]); + const part = assertRecord(parts[0]); expect(part.thought).toBe(true); expect(part.thoughtSignature).toBe("c2ln"); }); @@ -558,7 +558,7 @@ describe("google-shared convertMessages", () => { const toolResponsePart = expectDefined(contents[2], "contents[2] test invariant").parts?.find( (part) => typeof part === "object" && part !== null && "functionResponse" in part, ); - const toolResponse = asRecord(toolResponsePart); + const toolResponse = assertRecord(toolResponsePart); expect(requireRecordProperty(toolResponse, "functionResponse").name).toBe("myTool"); expect(expectDefined(contents[3], "contents[3] test invariant").role).toBe("user"); }); @@ -588,7 +588,7 @@ describe("google-shared convertMessages", () => { const toolCallPart = expectDefined(contents[2], "contents[2] test invariant").parts?.find( (part) => typeof part === "object" && part !== null && "functionCall" in part, ); - const toolCall = asRecord(toolCallPart); + const toolCall = assertRecord(toolCallPart); expect(requireRecordProperty(toolCall, "functionCall").name).toBe("myTool"); }); @@ -629,11 +629,11 @@ describe("google-shared convertMessages", () => { (part) => typeof part === "object" && part !== null && "functionResponse" in part, ); - const toolCall = asRecord(toolCallPart); - const toolResponse = asRecord(toolResponsePart); + const toolCall = assertRecord(toolCallPart); + const toolResponse = assertRecord(toolResponsePart); - expect(asRecord(toolCall.functionCall).id).toBeUndefined(); - expect(asRecord(toolResponse.functionResponse).id).toBeUndefined(); + expect(assertRecord(toolCall.functionCall).id).toBeUndefined(); + expect(assertRecord(toolResponse.functionResponse).id).toBeUndefined(); }); it("serializes structured tool results into function responses", () => { @@ -655,8 +655,8 @@ describe("google-shared convertMessages", () => { (part) => typeof part === "object" && part !== null && "functionResponse" in part, ); expect(toolResponsePart).toBeDefined(); - const toolResponse = requireRecordProperty(asRecord(toolResponsePart), "functionResponse"); - expect(asRecord(toolResponse.response).output).toBe( + const toolResponse = requireRecordProperty(assertRecord(toolResponsePart), "functionResponse"); + expect(assertRecord(toolResponse.response).output).toBe( '{"type":"json","payload":{"sessionKey":"current","status":"ok"}}', ); }); diff --git a/packages/ai/src/providers/google-shared.test-helpers.ts b/packages/ai/src/providers/google-shared.test-helpers.ts index 2364755c89e9..5868ed23cd6a 100644 --- a/packages/ai/src/providers/google-shared.test-helpers.ts +++ b/packages/ai/src/providers/google-shared.test-helpers.ts @@ -19,7 +19,7 @@ function makeZeroUsageSnapshot() { }; } -export const asRecord = (value: unknown): Record => { +export const assertRecord = (value: unknown): Record => { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("expected record"); } @@ -34,8 +34,8 @@ type ConvertedTools = ReadonlyArray<{ }>; export const getFirstToolParameters = (converted: ConvertedTools): Record => { - const functionDeclaration = asRecord(converted?.[0]?.functionDeclarations?.[0]); - return asRecord(functionDeclaration.parametersJsonSchema ?? functionDeclaration.parameters); + const functionDeclaration = assertRecord(converted?.[0]?.functionDeclarations?.[0]); + return assertRecord(functionDeclaration.parametersJsonSchema ?? functionDeclaration.parameters); }; export const makeModel = (id: string): Model<"google-generative-ai"> => diff --git a/packages/ai/src/utils/provider-error.test.ts b/packages/ai/src/utils/provider-error.test.ts index 445cdd4d3174..56c527e812d5 100644 --- a/packages/ai/src/utils/provider-error.test.ts +++ b/packages/ai/src/utils/provider-error.test.ts @@ -56,6 +56,23 @@ describe("projectProviderError", () => { ); }); + it("normalizes string and finite-number provider error fields", () => { + expect( + projectProviderError({ + message: " provider failure ", + code: 12.5, + type: " upstream ", + }), + ).toMatchObject({ + errorMessage: "provider failure", + errorCode: "12.5", + errorType: "upstream", + }); + expect( + projectProviderError({ message: "failed", code: Number.POSITIVE_INFINITY }), + ).not.toHaveProperty("errorCode"); + }); + it("bounds repeated aliases without expanding the shared graph", () => { const shared = { detail: "safe" }; diff --git a/packages/ai/src/utils/provider-error.ts b/packages/ai/src/utils/provider-error.ts index 845be603f2de..45a19664924d 100644 --- a/packages/ai/src/utils/provider-error.ts +++ b/packages/ai/src/utils/provider-error.ts @@ -1,4 +1,5 @@ import { asOptionalRecord, stableStringify } from "@openclaw/normalization-core"; +import { normalizeStringifiedOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { projectDiagnosticValue, redactDiagnosticText } from "./credential-redaction.js"; @@ -20,12 +21,14 @@ export function configureProviderErrorRedactor(redactor: ProviderErrorRedactor | return previous; } -function readString(record: Record | undefined, key: string): string | undefined { +function readProviderErrorField( + record: Record | undefined, + key: string, +): string | undefined { const value = record?.[key]; - if (typeof value === "string") { - return value.trim() || undefined; - } - return typeof value === "number" && Number.isFinite(value) ? String(value) : undefined; + return typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) + ? normalizeStringifiedOptionalString(value) + : undefined; } function readCauseCode(error: Record): string | undefined { @@ -36,7 +39,7 @@ function readCauseCode(error: Record): string | undefined { cause = asOptionalRecord(cause.cause) ) { seen.add(cause); - const code = readString(cause, "code"); + const code = readProviderErrorField(cause, "code"); if (code) { return code; } @@ -69,8 +72,8 @@ function buildProjection(snapshot: unknown, signal?: AbortSignal): ProviderError const body = stringifyField(bodyValue, MAX_ERROR_BODY_LENGTH); const originalMessage = (typeof snapshot === "string" ? snapshot : undefined) ?? - readString(error, "message") ?? - readString(nestedError, "message"); + readProviderErrorField(error, "message") ?? + readProviderErrorField(nestedError, "message"); const genericMessage = originalMessage === `${status} status code (no body)`; let errorMessage = status !== undefined && body && (!originalMessage || genericMessage) @@ -93,13 +96,15 @@ function buildProjection(snapshot: unknown, signal?: AbortSignal): ProviderError errorMessage += `\n${rawMetadata}`; } const errorCode = - readString(error, "errorCode") ?? - readString(error, "code") ?? - readString(nestedError, "code") ?? + readProviderErrorField(error, "errorCode") ?? + readProviderErrorField(error, "code") ?? + readProviderErrorField(nestedError, "code") ?? (error ? readCauseCode(error) : undefined) ?? (status === undefined ? undefined : String(status)); const errorType = - readString(error, "errorType") ?? readString(error, "type") ?? readString(nestedError, "type"); + readProviderErrorField(error, "errorType") ?? + readProviderErrorField(error, "type") ?? + readProviderErrorField(nestedError, "type"); return { stopReason: signal?.aborted ? "aborted" : "error", errorMessage: stringifyField(errorMessage, 4096) ?? "Unknown provider error", diff --git a/packages/normalization-core/src/number-coercion.test.ts b/packages/normalization-core/src/number-coercion.test.ts index 0ea660c47b51..4798e5b0b1c2 100644 --- a/packages/normalization-core/src/number-coercion.test.ts +++ b/packages/normalization-core/src/number-coercion.test.ts @@ -10,9 +10,11 @@ import { clampTimerTimeoutMs, finiteSecondsToTimerSafeMilliseconds, isFutureDateTimestampMs, + MAX_DATE_TIMESTAMP_MS, MAX_TIMER_TIMEOUT_MS, MAX_TIMER_TIMEOUT_SECONDS, nonNegativeSecondsToSafeMilliseconds, + parseDateTimestampMs, parseFiniteNumber, positiveSecondsToSafeMilliseconds, resolveIntegerOption, @@ -139,6 +141,35 @@ describe("number-coercion", () => { expect(timestampMsToIsoString("0")).toBeUndefined(); }); + test.each([ + { value: 0, expected: 0 }, + { value: 1_700_000_000_000, expected: 1_700_000_000_000 }, + { value: "0", expected: 0 }, + { value: " 1e3 ", expected: 1_000 }, + { value: "2026-07-13T10:00:00.000Z", expected: 1_783_936_800_000 }, + { value: -MAX_DATE_TIMESTAMP_MS, expected: -MAX_DATE_TIMESTAMP_MS }, + { value: String(MAX_DATE_TIMESTAMP_MS), expected: MAX_DATE_TIMESTAMP_MS }, + ])("parseDateTimestampMs parses $value as milliseconds", ({ value, expected }) => { + expect(parseDateTimestampMs(value)).toBe(expected); + }); + + test.each([ + new Date("2026-07-13T10:00:00.000Z"), + true, + false, + "", + " ", + "123ms", + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + MAX_DATE_TIMESTAMP_MS + 1, + String(MAX_DATE_TIMESTAMP_MS + 1), + "+275761-01-01T00:00:00.000Z", + ])("parseDateTimestampMs rejects %o", (value) => { + expect(parseDateTimestampMs(value)).toBeUndefined(); + }); + test("future timestamp helper rejects invalid Date timestamps", () => { expect(isFutureDateTimestampMs(1_001, { nowMs: 1_000 })).toBe(true); expect(isFutureDateTimestampMs(1_000, { nowMs: 1_000 })).toBe(false); diff --git a/packages/normalization-core/src/number-coercion.ts b/packages/normalization-core/src/number-coercion.ts index dc55b26cb79f..55d23d99a5c1 100644 --- a/packages/normalization-core/src/number-coercion.ts +++ b/packages/normalization-core/src/number-coercion.ts @@ -117,6 +117,18 @@ export function asDateTimestampMs(value: unknown): number | undefined { }); } +/** Parses Date-valid timestamps; numeric values and tokens are always milliseconds. */ +export function parseDateTimestampMs(value: unknown): number | undefined { + const numeric = parseFiniteNumber(value); + if (numeric !== undefined) { + return asDateTimestampMs(numeric); + } + if (typeof value !== "string") { + return undefined; + } + return asDateTimestampMs(Date.parse(value)); +} + /** Checks whether a Date-valid timestamp is after the supplied/current time. */ export function isFutureDateTimestampMs( value: unknown, diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 074a39711418..b81fabb3aaf9 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -85,7 +85,7 @@ function runStatusFromWaitPayload(payload: unknown): RunResult["status"] { timeoutPhase === "post_turn"); const hasTerminalTimeoutMetadata = readOptionalTimestamp(record.endedAt) !== undefined || - (!pendingError && readOptionalString(record.error) !== undefined) || + (!pendingError && readNonEmptyString(record.error) !== undefined) || stopReason.length > 0 || typeof record.livenessState === "string" || record.yielded === true; @@ -132,7 +132,7 @@ function runStatusFromWaitPayload(payload: unknown): RunResult["status"] { return "failed"; } -function readOptionalString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -659,14 +659,14 @@ export class Run { ); const record = asRecord(raw); const status = runStatusFromWaitPayload(raw); - const error = readOptionalString(record.error) - ? { message: readOptionalString(record.error) ?? "run failed" } + const error = readNonEmptyString(record.error) + ? { message: readNonEmptyString(record.error) ?? "run failed" } : undefined; return { runId: this.id, status, - sessionKey: readOptionalString(record.sessionKey) ?? this.sessionKey, - sessionId: readOptionalString(record.sessionId), + sessionKey: readNonEmptyString(record.sessionKey) ?? this.sessionKey, + sessionId: readNonEmptyString(record.sessionId), startedAt: readOptionalTimestamp(record.startedAt), endedAt: readOptionalTimestamp(record.endedAt), ...(error ? { error } : {}), @@ -702,7 +702,7 @@ export class Session { ...(timeoutMs !== undefined ? { timeoutMs: timeoutMs === 0 ? null : timeoutMs } : {}), }); const record = asRecord(raw); - const runId = readOptionalString(record.runId); + const runId = readNonEmptyString(record.runId); if (!runId) { throw new Error("sessions.send did not return a runId"); } @@ -767,7 +767,7 @@ export class SessionsNamespace { const raw = await this.client.request("sessions.create", params); const record = asRecord(raw); const key = - readOptionalString(record.key) ?? readOptionalString(record.sessionKey) ?? params.key; + readNonEmptyString(record.key) ?? readNonEmptyString(record.sessionKey) ?? params.key; if (!key) { throw new Error("sessions.create did not return a session key"); } @@ -800,11 +800,11 @@ export class RunsNamespace { ...(timeoutMs !== undefined ? { timeoutMs: timeoutMs === 0 ? null : timeoutMs } : {}), }); const record = asRecord(raw); - const runId = readOptionalString(record.runId); + const runId = readNonEmptyString(record.runId); if (!runId) { throw new Error("agent did not return a runId"); } - return new Run(this.client, runId, readOptionalString(record.sessionKey) ?? params.sessionKey); + return new Run(this.client, runId, readNonEmptyString(record.sessionKey) ?? params.sessionKey); } async get(runId: string): Promise { diff --git a/packages/sdk/src/normalize.ts b/packages/sdk/src/normalize.ts index e2d62e8120ef..baccac0c16d2 100644 --- a/packages/sdk/src/normalize.ts +++ b/packages/sdk/src/normalize.ts @@ -1,14 +1,14 @@ // OpenClaw SDK helper module supports normalize behavior. -import { asFiniteNumber as readNumber } from "@openclaw/normalization-core/number-coercion"; +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; import { asRecord } from "@openclaw/normalization-core/record-coerce"; import type { GatewayEvent, JsonObject, OpenClawEvent, OpenClawEventType } from "./types.js"; -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function readLowerString(value: unknown): string | undefined { - return readString(value)?.toLowerCase(); + return readNonEmptyString(value)?.toLowerCase(); } function hasHardTimeoutMetadata(data: JsonObject, statusAlreadyTimeoutAttributed = false): boolean { @@ -68,10 +68,10 @@ function normalizeLifecycleEndEventType(data: JsonObject): OpenClawEventType { } function normalizeAgentEventType(payload: JsonObject): OpenClawEventType { - const stream = readString(payload.stream); + const stream = readNonEmptyString(payload.stream); const data = asRecord(payload.data); - const phase = readString(data.phase); - const status = readString(data.status); + const phase = readNonEmptyString(data.phase); + const status = readNonEmptyString(data.status); if (stream === "assistant") { return data.delta === true || typeof data.delta === "string" @@ -134,7 +134,7 @@ function normalizeNamedEventType(event: GatewayEvent): OpenClawEventType { case "agent": return normalizeAgentEventType(payload); case "sessions.changed": { - const reason = readString(payload.reason); + const reason = readNonEmptyString(payload.reason); if (reason === "create") { return "session.created"; } @@ -164,12 +164,12 @@ function normalizeNamedEventType(event: GatewayEvent): OpenClawEventType { /** Normalize a raw Gateway event into the public SDK event shape. */ export function normalizeGatewayEvent(event: GatewayEvent): OpenClawEvent { const payload = asRecord(event.payload); - const runId = readString(payload.runId); - const sessionId = readString(payload.sessionId); - const sessionKey = readString(payload.sessionKey); - const taskId = readString(payload.taskId); - const agentId = readString(payload.agentId); - const ts = readNumber(payload.ts) ?? Date.now(); + const runId = readNonEmptyString(payload.runId); + const sessionId = readNonEmptyString(payload.sessionId); + const sessionKey = readNonEmptyString(payload.sessionKey); + const taskId = readNonEmptyString(payload.taskId); + const agentId = readNonEmptyString(payload.agentId); + const ts = asFiniteNumber(payload.ts) ?? Date.now(); const idParts = [event.seq ?? "local", event.event, runId, sessionKey, ts].filter( (part) => part !== undefined, ); diff --git a/qa/convex-credential-broker/convex/http.ts b/qa/convex-credential-broker/convex/http.ts index e888c5a46f69..e0cd97fadd17 100644 --- a/qa/convex-credential-broker/convex/http.ts +++ b/qa/convex-credential-broker/convex/http.ts @@ -98,7 +98,7 @@ function assertMaintainerAdminAuth(token: string | null) { throw new BrokerHttpError(401, "AUTH_INVALID", "Credential broker secret is invalid."); } -function asObject(value: unknown) { +function readJsonObject(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } @@ -112,7 +112,7 @@ async function parseJsonObject(request: Request) { } catch { throw new BrokerHttpError(400, "INVALID_JSON", "Request body must be valid JSON."); } - const body = asObject(parsed); + const body = readJsonObject(parsed); if (!body) { throw new BrokerHttpError(400, "INVALID_BODY", "Request body must be a JSON object."); } @@ -131,7 +131,7 @@ function requireString(body: Record, key: string) { return value; } -function optionalString(body: Record, key: string) { +function readOptionalHttpString(body: Record, key: string) { if (!(key in body) || body[key] === undefined || body[key] === null) { return undefined; } @@ -145,7 +145,7 @@ function optionalString(body: Record, key: string) { function requireObject(body: Record, key: string) { const raw = body[key]; - const parsed = asObject(raw); + const parsed = readJsonObject(raw); if (!parsed) { throw new BrokerHttpError(400, "INVALID_BODY", `Expected "${key}" to be a JSON object.`); } @@ -189,7 +189,7 @@ function optionalBoolean(body: Record, key: string) { } function optionalCredentialStatus(body: Record, key: string) { - const value = optionalString(body, key); + const value = readOptionalHttpString(body, key); if (!value) { return undefined; } @@ -204,7 +204,7 @@ function optionalCredentialStatus(body: Record, key: string) { } function optionalListStatus(body: Record, key: string) { - const value = optionalString(body, key); + const value = readOptionalHttpString(body, key); if (!value) { return undefined; } @@ -406,8 +406,8 @@ http.route({ const result = await ctx.runMutation(internal.credentials.addCredentialSet, { kind, payload, - note: optionalString(body, "note"), - actorId: optionalString(body, "actorId"), + note: readOptionalHttpString(body, "note"), + actorId: readOptionalHttpString(body, "actorId"), status: optionalCredentialStatus(body, "status"), }); return jsonResponse(200, result); @@ -429,7 +429,7 @@ http.route({ credentialId: normalizeCredentialId( requireString(body, "credentialId"), ) as Id<"credential_sets">, - actorId: optionalString(body, "actorId"), + actorId: readOptionalHttpString(body, "actorId"), }); return jsonResponse(200, result); } catch (error) { @@ -447,7 +447,7 @@ http.route({ assertMaintainerAdminAuth(parseBearerToken(request)); const body = await parseJsonObject(request); const result = await ctx.runQuery(internal.credentials.listCredentialSets, { - kind: optionalString(body, "kind"), + kind: readOptionalHttpString(body, "kind"), status: optionalListStatus(body, "status"), includePayload: optionalBoolean(body, "includePayload"), limit: optionalPositiveInteger(body, "limit"), diff --git a/qa/convex-credential-broker/convex/payload_validation.ts b/qa/convex-credential-broker/convex/payload_validation.ts index 346c98bc51ea..8dc11387f9f4 100644 --- a/qa/convex-credential-broker/convex/payload_validation.ts +++ b/qa/convex-credential-broker/convex/payload_validation.ts @@ -163,14 +163,14 @@ function normalizeBuzzCredentialPayload( 'Credential payload for kind "buzz" must use distinct driver and SUT identities.', ); } - const optionalString = (key: "driverAuthTag" | "sutAuthTag") => { + const readOptionalBuzzAuthTag = (key: "driverAuthTag" | "sutAuthTag") => { if (payload[key] === undefined) { return undefined; } return requireBuzzAuthTag(payload, key, createFailure); }; - const driverAuthTag = optionalString("driverAuthTag"); - const sutAuthTag = optionalString("sutAuthTag"); + const driverAuthTag = readOptionalBuzzAuthTag("driverAuthTag"); + const sutAuthTag = readOptionalBuzzAuthTag("sutAuthTag"); return { relayUrl, diff --git a/scripts/bench-agent-concurrency-worker.ts b/scripts/bench-agent-concurrency-worker.ts index 8a7f6750c64c..df90c42dac40 100644 --- a/scripts/bench-agent-concurrency-worker.ts +++ b/scripts/bench-agent-concurrency-worker.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { performance } from "node:perf_hooks"; import { pathToFileURL } from "node:url"; +import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; import type { SubagentRunRecord } from "../src/agents/subagents/registry/subagent-registry.types.js"; import type { DB as OpenClawStateKyselyDatabase } from "../src/state/openclaw-state-db.generated.js"; import { @@ -71,10 +72,6 @@ function processMaxRssBytes(): number { return Math.max(0, Math.round(process.resourceUsage().maxRSS * 1024)); } -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - async function waitForCondition(check: () => boolean): Promise { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { @@ -481,7 +478,7 @@ async function runSpawnSample( } } if (failure) { - throw toError(failure); + throw toErrorObject(failure, "Agent concurrency benchmark failed"); } const postTeardownTasks = await listBenchmarkTaskMemory(); const postTeardownRegistryRows = registry.subagentRuns.size; @@ -809,7 +806,7 @@ async function main(): Promise { } } if (failure) { - throw toError(failure); + throw toErrorObject(failure, "Agent concurrency benchmark failed"); } if (!result) { throw new Error("benchmark worker completed without a result"); diff --git a/scripts/bench-task-registry-sqlite-worker.ts b/scripts/bench-task-registry-sqlite-worker.ts index 7dc581c3472b..524d00883bf1 100644 --- a/scripts/bench-task-registry-sqlite-worker.ts +++ b/scripts/bench-task-registry-sqlite-worker.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { performance } from "node:perf_hooks"; import { pathToFileURL } from "node:url"; +import { toErrorObject } from "@openclaw/normalization-core/error-coercion"; import type { DB as OpenClawStateKyselyDatabase } from "../src/state/openclaw-state-db.generated.js"; import { WORKER_RESULT_SENTINEL, @@ -411,10 +412,6 @@ async function runBenchmark(options: WorkerOptions): Promise { }; } -function toError(error: unknown): Error { - return error instanceof Error ? error : new Error(String(error)); -} - async function main(): Promise { const options = parseOptions(process.argv.slice(2)); const previousStateDir = process.env.OPENCLAW_STATE_DIR; @@ -448,7 +445,7 @@ async function main(): Promise { } } if (failure) { - throw toError(failure); + throw toErrorObject(failure, "Task registry SQLite benchmark failed"); } if (!result) { throw new Error("benchmark worker completed without a result"); diff --git a/scripts/check-changed.mts b/scripts/check-changed.mts index dc45f40c884a..ad6f0a5ef7fc 100644 --- a/scripts/check-changed.mts +++ b/scripts/check-changed.mts @@ -620,6 +620,7 @@ export function createChangedCheckPlan( add("guarded extension wildcard re-exports", ["lint:extensions:no-guarded-wildcard-reexports"]); add("plugin-sdk wildcard re-exports", ["lint:extensions:no-plugin-sdk-wildcard-reexports"]); add("duplicate scan target coverage", ["dup:check:coverage"]); + add("coercion helper declaration guard", ["check:coercion-helpers"]); add("dependency pin guard", ["deps:pins:check"]); if (result.paths.length > 0) { add("format changed files", [ diff --git a/scripts/check-coercion-helper-declarations.mts b/scripts/check-coercion-helper-declarations.mts new file mode 100644 index 000000000000..9a1932f9bbfa --- /dev/null +++ b/scripts/check-coercion-helper-declarations.mts @@ -0,0 +1,417 @@ +#!/usr/bin/env node + +// Prevents local primitive-coercion helpers from regrowing after consolidation. +import fs from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { isCodeFile, listRepoFilesSync } from "./check-file-utils.js"; +import { isDirectRunUrl } from "./lib/direct-run.mjs"; +import { runWithFailedTrailer } from "./lib/failed-trailer.mts"; +import { resolveRepoRoot } from "./lib/repo-root.mjs"; +import { toLine, unwrapExpression } from "./lib/ts-guard-utils.mts"; + +const BANNED_HELPER_NAMES = new Set([ + "asObject", + "asRecord", + "asString", + "isRecord", + "normalizeString", + "optionalString", + "readBoolean", + "readNumber", + "readOptionalString", + "readString", + "timestampMs", + "toError", +]); +const SCAN_ROOTS = [ + "apps", + "deploy", + "examples", + "extensions", + "packages", + "qa", + "scripts", + "security", + "src", + "test", + "ui", +]; +const GENERATED_OR_FIXTURE_PATH_RE = + /(?:^|\/)(?:\.generated|__generated__|build|coverage|dist|generated|fixtures|node_modules|test-fixtures|vendor)(?:\/|$)|(?:^|\/)[^/]*(?:test-)?fixtures?\.[cm]?[jt]sx?$|\.generated\.[^/]+$|\.(?:bundle|min)\.[cm]?[jt]sx?$/u; +const GENERATED_BROWSER_RUNTIME_PATHS = new Set([ + "extensions/browser/chrome-extension/modules/copilot-runtime.js", +]); + +export type BannedCoercionHelperName = + | "asObject" + | "asRecord" + | "asString" + | "isRecord" + | "normalizeString" + | "optionalString" + | "readBoolean" + | "readNumber" + | "readOptionalString" + | "readString" + | "timestampMs" + | "toError"; + +export type CoercionHelperDeclaration = { + file: string; + kind: "function" | "variable"; + line: number; + name: BannedCoercionHelperName; +}; + +export type CoercionHelperCarveOut = { + count: number; + file: string; + name: BannedCoercionHelperName; + reason: string; +}; + +export const COERCION_HELPER_CARVE_OUTS: readonly CoercionHelperCarveOut[] = [ + { + file: "packages/normalization-core/src/record-coerce.ts", + name: "asRecord", + count: 1, + reason: "Canonical object-to-record fallback coercion owned by normalization-core.", + }, + { + file: "packages/normalization-core/src/record-coerce.ts", + name: "isRecord", + count: 1, + reason: "Canonical non-array record predicate owned by normalization-core.", + }, + { + file: "packages/llm-core/src/validation.ts", + name: "isRecord", + count: 1, + reason: + "Dependency-free validator intentionally accepts arrays before JSON type-specific checks.", + }, + { + file: "ui/src/test-helpers/control-ui-e2e.ts", + name: "isRecord", + count: 1, + reason: "Serialized mock Gateway closure cannot capture module imports.", + }, + { + file: "scripts/android-release-signing.mjs", + name: "asRecord", + count: 1, + reason: "Release signing entrypoint runs before workspace dependencies are installed.", + }, + { + file: "scripts/changed-lanes.mts", + name: "isRecord", + count: 1, + reason: "Changed-lane classification also runs in temporary repositories.", + }, + { + file: "scripts/check-built-plugin-control-plane-modules.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone build guard cannot rely on workspace package resolution.", + }, + { + file: "scripts/close-duplicate-prs-after-merge.mjs", + name: "isRecord", + count: 1, + reason: "Plain-Node Actions entrypoint runs in checkout-only jobs without dependencies.", + }, + { + file: "scripts/copy-bundled-plugin-metadata.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone metadata closure cannot rely on workspace package resolution.", + }, + { + file: "scripts/lib/kova-report-gate.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone report gate cannot rely on workspace package resolution.", + }, + { + file: "scripts/lib/plugin-npm-package-manifest.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone package-manifest closure cannot resolve workspace packages.", + }, + { + file: "scripts/lib/record-shared.mjs", + name: "isRecord", + count: 1, + reason: "Plain-Node shared helper serves MJS and E2E callers without package resolution.", + }, + { + file: "scripts/lib/static-extension-assets.mts", + name: "asRecord", + count: 1, + reason: "Copied standalone asset closure cannot rely on workspace package resolution.", + }, + { + file: "scripts/periphery-intersection.mjs", + name: "isRecord", + count: 1, + reason: "Plain-Node Actions entrypoint runs in checkout-only jobs without dependencies.", + }, + { + file: "scripts/pr-lib/process-group-runner.mjs", + name: "toError", + count: 1, + reason: + "Bootstrap process supervisor preserves fallback errors without workspace dependencies.", + }, + { + file: "scripts/stage-bundled-plugin-runtime.mts", + name: "isRecord", + count: 1, + reason: "Copied standalone runtime-staging closure cannot resolve workspace packages.", + }, +]; + +type CarveOutMismatch = CoercionHelperCarveOut & { + actualCount: number; + lines: number[]; +}; + +type CoercionHelperAudit = { + excessDeclarations: CoercionHelperDeclaration[]; + invalidCarveOuts: string[]; + staleCarveOuts: CarveOutMismatch[]; +}; + +type ScriptIo = { + stderr: { write(value: string): unknown }; + stdout: { write(value: string): unknown }; +}; + +function carveOutKey(entry: Pick) { + return `${entry.file}\0${entry.name}`; +} + +function unwrapCallableInitializer(expression: ts.Expression) { + let current = unwrapExpression(expression); + while (ts.isSatisfiesExpression(current)) { + current = unwrapExpression(current.expression); + } + return current; +} + +function unwrapDirectAliasInitializer(expression: ts.Expression): ts.Expression | undefined { + let current = expression; + while (true) { + if (ts.isParenthesizedExpression(current) || ts.isNonNullExpression(current)) { + current = current.expression; + continue; + } + if (ts.isSatisfiesExpression(current)) { + current = current.expression; + continue; + } + if (ts.isAsExpression(current) || ts.isTypeAssertionExpression(current)) { + return undefined; + } + return current; + } +} + +/** Returns true for tracked source files governed by the declaration guard. */ +export function isGovernedCoercionHelperPath(filePath: string) { + return ( + isCodeFile(filePath) && + !/\.d\.[cm]?ts$/u.test(filePath) && + !GENERATED_BROWSER_RUNTIME_PATHS.has(filePath) && + !GENERATED_OR_FIXTURE_PATH_RE.test(filePath) + ); +} + +/** Finds banned function and callable-variable declarations in one source file. */ +export function findBannedCoercionHelperDeclarations( + source: string, + file = "source.ts", +): CoercionHelperDeclaration[] { + if (![...BANNED_HELPER_NAMES].some((name) => source.includes(name))) { + return []; + } + const scriptKind = file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind); + const declarations: CoercionHelperDeclaration[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.name && BANNED_HELPER_NAMES.has(node.name.text)) { + declarations.push({ + file, + kind: "function", + line: toLine(sourceFile, node.name), + name: node.name.text as BannedCoercionHelperName, + }); + } else if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + BANNED_HELPER_NAMES.has(node.name.text) && + node.initializer + ) { + const initializer = unwrapCallableInitializer(node.initializer); + const aliasInitializer = unwrapDirectAliasInitializer(node.initializer); + if ( + ts.isArrowFunction(initializer) || + ts.isFunctionExpression(initializer) || + (aliasInitializer !== undefined && + (ts.isIdentifier(aliasInitializer) || ts.isPropertyAccessExpression(aliasInitializer))) + ) { + declarations.push({ + file, + kind: "variable", + line: toLine(sourceFile, node.name), + name: node.name.text as BannedCoercionHelperName, + }); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return declarations; +} + +/** Checks exact file/name/count carve-outs and rejects stale or excess entries. */ +export function auditCoercionHelperDeclarations( + declarations: readonly CoercionHelperDeclaration[], + carveOuts: readonly CoercionHelperCarveOut[], +): CoercionHelperAudit { + const invalidCarveOuts: string[] = []; + const carveOutByKey = new Map(); + for (const carveOut of carveOuts) { + const key = carveOutKey(carveOut); + if (carveOutByKey.has(key)) { + invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] is listed more than once`); + continue; + } + if (!BANNED_HELPER_NAMES.has(carveOut.name)) { + invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] is not a banned helper name`); + } + if (!Number.isInteger(carveOut.count) || carveOut.count < 1) { + invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] must have a positive count`); + } + if (!carveOut.reason.trim()) { + invalidCarveOuts.push(`${carveOut.file} [${carveOut.name}] needs a non-empty reason`); + } + carveOutByKey.set(key, carveOut); + } + + const declarationsByKey = new Map(); + for (const declaration of declarations) { + const key = carveOutKey(declaration); + const current = declarationsByKey.get(key) ?? []; + current.push(declaration); + declarationsByKey.set(key, current); + } + + const excessDeclarations: CoercionHelperDeclaration[] = []; + for (const [key, actual] of declarationsByKey) { + const allowedCount = carveOutByKey.get(key)?.count ?? 0; + if (actual.length > allowedCount) { + excessDeclarations.push(...actual.slice(allowedCount)); + } + } + const staleCarveOuts = carveOuts + .map((carveOut): CarveOutMismatch | null => { + const actual = declarationsByKey.get(carveOutKey(carveOut)) ?? []; + return actual.length < carveOut.count + ? { + ...carveOut, + actualCount: actual.length, + lines: actual.map((entry) => entry.line), + } + : null; + }) + .filter((entry): entry is CarveOutMismatch => entry !== null); + + return { + excessDeclarations: excessDeclarations.toSorted( + (left, right) => + left.file.localeCompare(right.file) || + left.line - right.line || + left.name.localeCompare(right.name), + ), + invalidCarveOuts, + staleCarveOuts, + }; +} + +function writeLine(stream: ScriptIo["stdout"] | ScriptIo["stderr"], value: string) { + stream.write(`${value}\n`); +} + +/** Runs the full tracked-source declaration guard. */ +export function runCoercionHelperDeclarationGuard( + options: { + carveOuts?: readonly CoercionHelperCarveOut[]; + io?: ScriptIo; + repoRoot?: string; + } = {}, +) { + const repoRoot = options.repoRoot ?? resolveRepoRoot(import.meta.url); + const io = options.io ?? { stderr: process.stderr, stdout: process.stdout }; + const carveOuts = options.carveOuts ?? COERCION_HELPER_CARVE_OUTS; + const relativeFiles = listRepoFilesSync(repoRoot, { + roots: SCAN_ROOTS, + includeFile: isGovernedCoercionHelperPath, + }); + const declarations = relativeFiles.flatMap((file) => { + const absolutePath = path.join(repoRoot, file); + if (!fs.existsSync(absolutePath)) { + return []; + } + return findBannedCoercionHelperDeclarations(fs.readFileSync(absolutePath, "utf8"), file); + }); + const audit = auditCoercionHelperDeclarations(declarations, carveOuts); + const failed = + audit.excessDeclarations.length > 0 || + audit.invalidCarveOuts.length > 0 || + audit.staleCarveOuts.length > 0; + if (!failed) { + writeLine( + io.stdout, + `Coercion helper declaration guard passed (${declarations.length} allowlisted declarations).`, + ); + return 0; + } + + if (audit.invalidCarveOuts.length > 0) { + writeLine(io.stderr, "Invalid coercion-helper carve-outs:"); + for (const message of audit.invalidCarveOuts) { + writeLine(io.stderr, `- ${message}`); + } + } + if (audit.excessDeclarations.length > 0) { + writeLine(io.stderr, "Banned local coercion-helper declarations:"); + for (const declaration of audit.excessDeclarations) { + writeLine( + io.stderr, + `- ${declaration.file}:${declaration.line} ${declaration.name} (${declaration.kind} declaration)`, + ); + } + } + if (audit.staleCarveOuts.length > 0) { + writeLine(io.stderr, "Stale coercion-helper carve-outs:"); + for (const carveOut of audit.staleCarveOuts) { + writeLine( + io.stderr, + `- ${carveOut.file} [${carveOut.name}] expected ${carveOut.count}, found ${carveOut.actualCount}; remove or reduce the carve-out`, + ); + } + } + writeLine( + io.stderr, + "Use @openclaw/normalization-core coercion subpaths in core/packages/UI/scripts, or openclaw/plugin-sdk/string-coerce-runtime and error-runtime in plugin production code.", + ); + return 1; +} + +if (isDirectRunUrl(process.argv[1], import.meta.url)) { + await runWithFailedTrailer("check:coercion-helpers", () => { + process.exitCode = runCoercionHelperDeclarationGuard(); + }); +} diff --git a/scripts/check-control-ui-performance.mts b/scripts/check-control-ui-performance.mts index e4901991a55d..e7dcf8385b74 100644 --- a/scripts/check-control-ui-performance.mts +++ b/scripts/check-control-ui-performance.mts @@ -5,7 +5,7 @@ import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -function isRecord(value: unknown): value is Record { +function isMetricsRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -269,7 +269,7 @@ function isIsoDate(value: string): boolean { function readControlUiStartupBudgetBaseline(baselinePath: string): ControlUiStartupBudgetBaseline { try { const parsed: unknown = JSON.parse(fs.readFileSync(baselinePath, "utf8")); - const record: Record = isRecord(parsed) ? parsed : {}; + const record: Record = isMetricsRecord(parsed) ? parsed : {}; const { startupJsGzipBytes, reason, updatedAt } = record; if ( typeof startupJsGzipBytes !== "number" || diff --git a/scripts/check-file-utils.ts b/scripts/check-file-utils.ts index 6df7f007eef2..8d9878582910 100644 --- a/scripts/check-file-utils.ts +++ b/scripts/check-file-utils.ts @@ -16,6 +16,7 @@ export const REPO_SCAN_SKIPPED_DIR_NAMES: ReadonlySet = new Set([ ]); // Bound the Git lookup before falling back to direct filesystem traversal. const GIT_LS_FILES_TIMEOUT_MS = 30_000; +const GIT_LS_FILES_MAX_BUFFER = 64 * 1024 * 1024; export function isCodeFile(filePath: string): boolean { if (filePath.endsWith(".d.ts")) { @@ -93,6 +94,7 @@ export function listRepoFilesSync( stdio: ["ignore", "pipe", "ignore"], timeout: GIT_LS_FILES_TIMEOUT_MS, killSignal: "SIGKILL", + maxBuffer: GIT_LS_FILES_MAX_BUFFER, }) .split(/\r?\n/u) .filter(Boolean) diff --git a/scripts/check-gateway-cpu-scenarios.mts b/scripts/check-gateway-cpu-scenarios.mts index d280811dc1e4..7c7ef33b047c 100644 --- a/scripts/check-gateway-cpu-scenarios.mts +++ b/scripts/check-gateway-cpu-scenarios.mts @@ -7,6 +7,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { pathToFileURL } from "node:url"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mts"; import { parseNonNegativeInt, @@ -71,10 +72,6 @@ type ConcurrencyReport = { }; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parseArgs(argv: string[]) { const args = stripLeadingPackageManagerSeparator(argv); const options = { diff --git a/scripts/check-memory-fd-repro.mts b/scripts/check-memory-fd-repro.mts index ad3ee8b46395..1d69d2798731 100644 --- a/scripts/check-memory-fd-repro.mts +++ b/scripts/check-memory-fd-repro.mts @@ -126,7 +126,7 @@ function stripPackageManagerSeparatorForKnownFlags(argv: string[]) { /** * Parses a safe non-negative integer option. */ -export function readNumber(value: unknown, label: string) { +export function parseNonNegativeInteger(value: unknown, label: string) { const raw = String(value).trim(); if (!NON_NEGATIVE_INTEGER_PATTERN.test(raw)) { throw new Error(`${label} must be a non-negative integer`); @@ -142,7 +142,7 @@ export function readNumber(value: unknown, label: string) { * Parses a safe positive integer option. */ export function readPositiveNumber(value: unknown, label: string) { - const parsed = readNumber(value, label); + const parsed = parseNonNegativeInteger(value, label); if (parsed <= 0) { throw new Error(`${label} must be greater than 0`); } @@ -151,7 +151,7 @@ export function readPositiveNumber(value: unknown, label: string) { function readNumberEnv(name: string, fallback: number) { const raw = process.env[name]; - return raw == null || raw.trim() === "" ? fallback : readNumber(raw, name); + return raw == null || raw.trim() === "" ? fallback : parseNonNegativeInteger(raw, name); } function readPositiveNumberEnv(name: string, fallback: number) { @@ -166,7 +166,8 @@ function clampTimerTimeoutMs(valueMs: number, minMs = 1) { } function readTimerTimeoutNumber(value: unknown, label: string, minMs = 1) { - const parsed = minMs > 0 ? readPositiveNumber(value, label) : readNumber(value, label); + const parsed = + minMs > 0 ? readPositiveNumber(value, label) : parseNonNegativeInteger(value, label); return clampTimerTimeoutMs(parsed, minMs); } @@ -230,7 +231,7 @@ export function parseArgs(argv: string[]) { mode = "report"; break; case "--max-workspace-reg-fds": - maxWorkspaceRegFds = readNumber(readValue(), "--max-workspace-reg-fds"); + maxWorkspaceRegFds = parseNonNegativeInteger(readValue(), "--max-workspace-reg-fds"); break; case "--min-leaked-fds": minLeakedFds = readPositiveNumber(readValue(), "--min-leaked-fds"); diff --git a/scripts/check-plugin-gateway-gauntlet.mts b/scripts/check-plugin-gateway-gauntlet.mts index 2956bfcef64b..8243d34fb74d 100644 --- a/scripts/check-plugin-gateway-gauntlet.mts +++ b/scripts/check-plugin-gateway-gauntlet.mts @@ -25,6 +25,9 @@ import { readQaSuiteSummary, selectPluginEntries, } from "./lib/plugin-gateway-gauntlet.mts"; +// Termination tests import this entrypoint in a child before publishing readiness. +// Keep its record guard on the dependency-light script seam to avoid startup skew. +import { isRecord } from "./lib/record-shared.mjs"; const DEFAULT_QA_SCENARIOS = [ "channel-chat-baseline", @@ -115,10 +118,6 @@ type GauntletContext = { }; type QaSummary = NonNullable["summary"]>; -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - /** * Parses plugin gateway gauntlet CLI arguments and env defaults. */ diff --git a/scripts/dependency-changes-report.mts b/scripts/dependency-changes-report.mts index 8930ae9ec9c2..cbb25279fe07 100644 --- a/scripts/dependency-changes-report.mts +++ b/scripts/dependency-changes-report.mts @@ -30,7 +30,7 @@ const DEPENDENCY_DIFF_PATHS = [ type DependencyPayload = Record; type DependencyFileChange = { oldPath: string | null; path: string; status: string }; -const optionalString = (value: string | null) => value; +const nullableString = (value: string | null) => value; function payloadFromLockfile(lockfileText: string): DependencyPayload { const packages = collectAllResolvedPackagesFromLockfile(lockfileText); @@ -261,11 +261,11 @@ function readRequiredValue(argv: string[], index: number, flag: string) { export function parseArgs(argv: string[]) { const options = { rootDir: process.cwd(), - baseRef: optionalString(null), - baseLockfile: optionalString(null), + baseRef: nullableString(null), + baseLockfile: nullableString(null), headLockfile: "pnpm-lock.yaml", - jsonPath: optionalString(null), - markdownPath: optionalString(null), + jsonPath: nullableString(null), + markdownPath: nullableString(null), }; const seen = new Set(); const setOnce = (flag: string, key: keyof typeof options, value: string) => { diff --git a/scripts/docker-e2e.mts b/scripts/docker-e2e.mts index 2820ce41dea2..d008c5da42be 100644 --- a/scripts/docker-e2e.mts +++ b/scripts/docker-e2e.mts @@ -1,15 +1,15 @@ // Docker E2E CI helper. // Converts scheduler JSON into GitHub Actions outputs and compact markdown // summaries so the workflow does not duplicate Docker E2E planning logic. -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { readDockerE2eJsonArtifact } from "./lib/docker-e2e-json-artifacts.mts"; -function asRecord(value: unknown) { - return isRecord(value) ? value : {}; +function recordOrEmpty(value: unknown) { + return asOptionalRecord(value) ?? {}; } function recordArray(value: unknown) { - return Array.isArray(value) ? value.map(asRecord) : []; + return Array.isArray(value) ? value.map(recordOrEmpty) : []; } function usage() { @@ -26,8 +26,8 @@ function boolOutput(value: unknown) { } function githubOutputs(value: unknown) { - const plan = asRecord(value); - const needs = asRecord(plan.needs); + const plan = recordOrEmpty(value); + const needs = recordOrEmpty(plan.needs); const credentials = Array.isArray(plan.credentials) ? plan.credentials.filter( (credential: unknown): credential is string => typeof credential === "string", @@ -76,7 +76,7 @@ function formatSeconds(value: unknown) { } function summaryMarkdown(value: unknown, title: string) { - const summary = asRecord(value); + const summary = recordOrEmpty(value); const lanes = recordArray(summary.lanes); const slowest = lanes .filter((lane) => Number.isFinite(Number(lane.elapsedSeconds))) @@ -127,7 +127,7 @@ function summaryMarkdown(value: unknown, title: string) { } function failedRerunCommands(value: unknown) { - const summary = asRecord(value); + const summary = recordOrEmpty(value); const lanes = recordArray(summary.lanes); return lanes.flatMap((lane) => lane.status !== 0 && lane.rerunCommand ? [String(lane.rerunCommand)] : [], diff --git a/scripts/e2e/lib/npm-telegram-live/prepare-package.mts b/scripts/e2e/lib/npm-telegram-live/prepare-package.mts index 7e0e5b02fb86..af95efd2d428 100644 --- a/scripts/e2e/lib/npm-telegram-live/prepare-package.mts +++ b/scripts/e2e/lib/npm-telegram-live/prepare-package.mts @@ -2,7 +2,7 @@ import fs from "node:fs"; import { privateLocalOnlyPluginSdkEntrypoints } from "../../../lib/plugin-sdk-entries.mts"; -function isRecord(value: unknown): value is Record { +function isPackageJsonRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -16,10 +16,12 @@ if (!packageJsonPath) { throw new Error("trusted harness package.json path is missing"); } const parsedPackageJson: unknown = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); -if (!isRecord(parsedPackageJson)) { +if (!isPackageJsonRecord(parsedPackageJson)) { throw new Error("trusted harness package.json must be an object"); } -const packageExports = isRecord(parsedPackageJson.exports) ? parsedPackageJson.exports : {}; +const packageExports = isPackageJsonRecord(parsedPackageJson.exports) + ? parsedPackageJson.exports + : {}; parsedPackageJson.exports = packageExports; // Private QA builds emit these two harness-only facades outside the regular SDK inventory. diff --git a/scripts/e2e/parallels/npm-update-smoke.ts b/scripts/e2e/parallels/npm-update-smoke.ts index dea377a1a4d8..b8fd35c8d362 100755 --- a/scripts/e2e/parallels/npm-update-smoke.ts +++ b/scripts/e2e/parallels/npm-update-smoke.ts @@ -12,6 +12,7 @@ import { finiteSecondsToTimerSafeMilliseconds, } from "@openclaw/normalization-core/number-coercion"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import prettyMilliseconds from "pretty-ms"; import { die, @@ -565,10 +566,6 @@ function parseOpenClawPackageSpecVersion(spec: string): string { return resolveOpenClawRegistryVersion(value) || ""; } -function readString(value: unknown): string { - return typeof value === "string" ? value : ""; -} - export function parseRegistryPackageMetadata(raw: string): { gitHead: string; tarball: string; @@ -585,9 +582,9 @@ export function parseRegistryPackageMetadata(raw: string): { } const dist = isRecord(parsed.dist) ? parsed.dist : {}; return { - gitHead: readString(parsed.gitHead), - tarball: readString(parsed["dist.tarball"]) || readString(dist.tarball), - version: readString(parsed.version), + gitHead: readStringValue(parsed.gitHead) ?? "", + tarball: readStringValue(parsed["dist.tarball"]) || readStringValue(dist.tarball) || "", + version: readStringValue(parsed.version) ?? "", }; } catch { return { gitHead: "", tarball: "", version: "" }; diff --git a/scripts/e2e/telegram-bot-api.ts b/scripts/e2e/telegram-bot-api.ts index ecbc19acca6b..709b47b63f23 100644 --- a/scripts/e2e/telegram-bot-api.ts +++ b/scripts/e2e/telegram-bot-api.ts @@ -1,4 +1,5 @@ // Telegram Bot Api script supports OpenClaw repository automation. +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { readBoundedResponseText } from "../lib/bounded-response.mjs"; import { readPositiveIntEnv } from "./lib/env-limits.mjs"; @@ -33,11 +34,6 @@ export function readTelegramBotApiLimits( const DEFAULT_LIMITS = readTelegramBotApiLimits(); -function optionalString(source: JsonObject, key: string) { - const value = source[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function taggedError(message: string, code: string) { return Object.assign(new Error(message), { code }); } @@ -90,7 +86,8 @@ export async function telegramBotApi( const payload = parseJsonPayload(rawPayload, label); if (!response.ok || payload.ok !== true) { throw new Error( - optionalString(payload, "description") ?? `${method} failed with HTTP ${response.status}`, + normalizeOptionalString(payload.description) ?? + `${method} failed with HTTP ${response.status}`, ); } return payload.result; diff --git a/scripts/e2e/telegram-user-credential.ts b/scripts/e2e/telegram-user-credential.ts index 994636073848..402be07ac36b 100644 --- a/scripts/e2e/telegram-user-credential.ts +++ b/scripts/e2e/telegram-user-credential.ts @@ -151,7 +151,7 @@ function requireString(source: JsonObject, key: string) { throw new Error(`Missing ${key}.`); } -function optionalString(source: JsonObject, key: string) { +function readOptionalCredentialString(source: JsonObject, key: string) { const value = source[key]; if (typeof value === "number") { return String(value); @@ -274,7 +274,7 @@ function joinBrokerEndpoint(siteUrl: string, endpoint: string) { function assertBrokerSuccess(payload: JsonObject, action: string) { if (payload.status === "error") { throw new Error( - `${action} failed: ${requireString(payload, "code")} ${optionalString(payload, "message") || ""}`.trim(), + `${action} failed: ${requireString(payload, "code")} ${readOptionalCredentialString(payload, "message") || ""}`.trim(), ); } if (payload.status !== "ok") { diff --git a/scripts/full-release-validation-at-sha.mts b/scripts/full-release-validation-at-sha.mts index 9394265562af..6d0270166a81 100644 --- a/scripts/full-release-validation-at-sha.mts +++ b/scripts/full-release-validation-at-sha.mts @@ -52,7 +52,7 @@ type TemporaryRefParams = { evidenceVerified: boolean; }; -function isRecord(value: unknown): value is Record { +function isJsonRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -355,8 +355,8 @@ function findLatestRunId(branch: string, sha: string) { if (!Array.isArray(runs)) { throw new Error("Full Release Validation run list response was not an array"); } - const match = runs.find((runItem: unknown) => isRecord(runItem) && runItem.headSha === sha); - const databaseId = isRecord(match) ? match.databaseId : undefined; + const match = runs.find((runItem: unknown) => isJsonRecord(runItem) && runItem.headSha === sha); + const databaseId = isJsonRecord(match) ? match.databaseId : undefined; return typeof databaseId === "string" || typeof databaseId === "number" ? String(databaseId) : ""; } @@ -367,7 +367,7 @@ function readWorkflowRun(parentRunId: string, workflowSha: string) { const workflowRun: unknown = JSON.parse( execGhRead(["api", `repos/openclaw/openclaw/actions/runs/${parentRunId}`], GH_READ_OPTIONS), ); - if (!isRecord(workflowRun)) { + if (!isJsonRecord(workflowRun)) { throw new Error(`Full Release Validation run ${parentRunId} returned an invalid response`); } if (workflowRun.head_sha !== workflowSha) { @@ -465,10 +465,10 @@ export function assertTrustedWorkflowHarness( ); } if ( - !isRecord(workflow) || - !isRecord(workflow.on) || - !isRecord(workflow.on.workflow_dispatch) || - !isRecord(workflow.on.workflow_dispatch.inputs) || + !isJsonRecord(workflow) || + !isJsonRecord(workflow.on) || + !isJsonRecord(workflow.on.workflow_dispatch) || + !isJsonRecord(workflow.on.workflow_dispatch.inputs) || !Object.hasOwn(workflow.on.workflow_dispatch.inputs, "expected_sha") ) { throw new Error( @@ -508,10 +508,10 @@ function verifyReleaseEvidence(parentRunId: string, workflowSha: string) { run(process.execPath, [verifier, ...releaseEvidenceVerificationArgs(parentRunId)]), ); if ( - !isRecord(evidence) || + !isJsonRecord(evidence) || evidence.valid !== true || - !isRecord(evidence.current) || - !isRecord(evidence.root) + !isJsonRecord(evidence.current) || + !isJsonRecord(evidence.root) ) { throw new Error(`Full Release Validation evidence is invalid for run ${parentRunId}.`); } diff --git a/scripts/lib/export-name-collision-baseline.json b/scripts/lib/export-name-collision-baseline.json index 21dca44ebf0e..8164a987396e 100644 --- a/scripts/lib/export-name-collision-baseline.json +++ b/scripts/lib/export-name-collision-baseline.json @@ -27,15 +27,6 @@ "src/plugins/uninstall.ts" ] }, - { - "name": "asString", - "files": [ - "src/channels/plugins/status-issues/shared.ts", - "src/cli/nodes-media-utils.ts", - "src/tui/tui-formatters.ts" - ], - "sdk": true - }, { "name": "authorizeConfigWrite", "files": [ @@ -1180,13 +1171,6 @@ "src/secrets/shared.ts" ] }, - { - "name": "toError", - "files": [ - "src/agents/prepared-model-runtime.owner.ts", - "src/worker/worker-connection-contract.ts" - ] - }, { "name": "toPosixPath", "files": [ diff --git a/scripts/lib/plugin-gateway-gauntlet.mts b/scripts/lib/plugin-gateway-gauntlet.mts index fdd18f839e17..87c4adda81ea 100644 --- a/scripts/lib/plugin-gateway-gauntlet.mts +++ b/scripts/lib/plugin-gateway-gauntlet.mts @@ -36,13 +36,13 @@ function readPositiveIntEnv(name: string, fallback: number) { return raw === undefined || raw === "" ? fallback : parsePositiveInt(raw, name); } -function normalizeString(value: unknown) { +function normalizeStringOrEmpty(value: unknown) { return typeof value === "string" ? value.trim() : ""; } function normalizeStringArray(value: unknown) { return Array.isArray(value) - ? value.map((entry) => normalizeString(entry)).filter((entry) => entry.length > 0) + ? value.map((entry) => normalizeStringOrEmpty(entry)).filter((entry) => entry.length > 0) : []; } @@ -102,20 +102,20 @@ function collectCommandAliasRecords(manifest: PluginManifest) { return aliases .map((alias) => { if (typeof alias === "string") { - const name = normalizeString(alias); + const name = normalizeStringOrEmpty(alias); return name ? { name, kind: "runtime-slash", cliCommand: null } : null; } if (!isRecord(alias)) { return null; } - const name = normalizeString(alias.name); + const name = normalizeStringOrEmpty(alias.name); if (!name) { return null; } return { name, - kind: normalizeString(alias.kind) || "runtime-slash", - cliCommand: normalizeString(alias.cliCommand) || null, + kind: normalizeStringOrEmpty(alias.kind) || "runtime-slash", + cliCommand: normalizeStringOrEmpty(alias.cliCommand) || null, }; }) .filter((alias) => alias !== null); @@ -124,7 +124,7 @@ function collectCommandAliasRecords(manifest: PluginManifest) { function collectAuthMethods(manifest: PluginManifest) { const auth = Array.isArray(manifest.auth) ? manifest.auth : []; return auth - .map((entry) => (isRecord(entry) ? normalizeString(entry.method) : "")) + .map((entry) => (isRecord(entry) ? normalizeStringOrEmpty(entry.method) : "")) .filter((method) => method.length > 0); } @@ -153,7 +153,7 @@ function buildPluginMatrixEntry(repoRoot: string, manifestPath: string, manifest return { id: manifest.id, buildId: path.basename(pluginDir), - name: normalizeString(manifest.name) || manifest.id, + name: normalizeStringOrEmpty(manifest.name) || manifest.id, dir: path.relative(repoRoot, pluginDir), manifestPath: relativeManifestPath, enabledByDefault: manifest.enabledByDefault === true, @@ -291,7 +291,7 @@ function median(values: unknown[]) { function groupByPhase(rows: JsonRecord[]) { const phases = new Map(); for (const row of rows) { - const phase = normalizeString(row.phase) || "unknown"; + const phase = normalizeStringOrEmpty(row.phase) || "unknown"; const current = phases.get(phase) ?? []; current.push(row); phases.set(phase, current); @@ -515,7 +515,7 @@ function collectGatewayCpuObservations(params: { if (!isRecord(result)) { continue; } - const id = normalizeString(result.id) || "unknown"; + const id = normalizeStringOrEmpty(result.id) || "unknown"; const cpuCoreMax = metricMax(result.summary, "cpuCoreRatio"); const wallMax = metricMax(result.summary, "readyzMs") ?? metricMax(result.summary, "healthzMs"); if ( diff --git a/scripts/lib/release-beta-verifier.ts b/scripts/lib/release-beta-verifier.ts index 356c1a17e767..007e18081751 100644 --- a/scripts/lib/release-beta-verifier.ts +++ b/scripts/lib/release-beta-verifier.ts @@ -92,20 +92,20 @@ const TRUSTED_TOOLING_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ". const NPM_VIEW_ATTEMPTS = 30; const NPM_VIEW_RETRY_MAX_DELAY_MS = 10_000; +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeOptionalText(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + function compareCodeUnits(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } -function isRecord(value: unknown): value is JsonRecord { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readString(value: unknown): string | undefined { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; -} - function requireString(value: unknown, label: string): string { - const stringValue = readString(value); + const stringValue = normalizeOptionalText(value); if (stringValue === undefined) { throw new Error(`${label} is missing.`); } @@ -120,11 +120,11 @@ function readTrustedClawHubToolchainIdentity(): { const lockPath = resolve(TRUSTED_TOOLING_ROOT, ".github/release/clawhub-cli/package-lock.json"); const lockBytes = readFileSync(lockPath); const lock = parseJson(lockBytes.toString("utf8"), "trusted ClawHub CLI package-lock.json"); - if (!isRecord(lock) || !isRecord(lock.packages)) { + if (!isJsonRecord(lock) || !isJsonRecord(lock.packages)) { throw new Error("Trusted ClawHub CLI package-lock.json is invalid."); } const clawhub = lock.packages["node_modules/clawhub"]; - if (!isRecord(clawhub)) { + if (!isJsonRecord(clawhub)) { throw new Error("Trusted ClawHub CLI package-lock.json is missing clawhub."); } const clawhubToolchainIntegrity = requireString( @@ -200,22 +200,25 @@ export function parseNpmViewFields(raw: string, distTag: string): NpmViewFields const parsed = parseJson(raw, "npm view"); if (Array.isArray(parsed)) { return { - version: readString(parsed[0]), - distTagVersion: readString(parsed[1]), - integrity: readString(parsed[2]), - tarball: readString(parsed[3]), + version: normalizeOptionalText(parsed[0]), + distTagVersion: normalizeOptionalText(parsed[1]), + integrity: normalizeOptionalText(parsed[2]), + tarball: normalizeOptionalText(parsed[3]), }; } - if (!isRecord(parsed)) { + if (!isJsonRecord(parsed)) { throw new Error("npm view returned an unsupported JSON shape."); } - const distTags = isRecord(parsed["dist-tags"]) ? parsed["dist-tags"] : undefined; - const dist = isRecord(parsed.dist) ? parsed.dist : undefined; + const distTags = isJsonRecord(parsed["dist-tags"]) ? parsed["dist-tags"] : undefined; + const dist = isJsonRecord(parsed.dist) ? parsed.dist : undefined; return { - version: readString(parsed.version), - distTagVersion: readString(parsed[`dist-tags.${distTag}`]) ?? readString(distTags?.[distTag]), - integrity: readString(parsed["dist.integrity"]) ?? readString(dist?.integrity), - tarball: readString(parsed["dist.tarball"]) ?? readString(dist?.tarball), + version: normalizeOptionalText(parsed.version), + distTagVersion: + normalizeOptionalText(parsed[`dist-tags.${distTag}`]) ?? + normalizeOptionalText(distTags?.[distTag]), + integrity: + normalizeOptionalText(parsed["dist.integrity"]) ?? normalizeOptionalText(dist?.integrity), + tarball: normalizeOptionalText(parsed["dist.tarball"]) ?? normalizeOptionalText(dist?.tarball), }; } @@ -519,11 +522,11 @@ async function verifyNpmPackage( } function readClawHubTags(detail: unknown): Record { - if (!isRecord(detail)) { + if (!isJsonRecord(detail)) { return {}; } - const packageDetail = isRecord(detail.package) ? detail.package : undefined; - const tags = isRecord(packageDetail?.tags) ? packageDetail.tags : undefined; + const packageDetail = isJsonRecord(detail.package) ? detail.package : undefined; + const tags = isJsonRecord(packageDetail?.tags) ? packageDetail.tags : undefined; const result: Record = {}; for (const [key, value] of Object.entries(tags ?? {})) { if (typeof value === "string") { @@ -576,7 +579,7 @@ function verifyGitHubRelease(params: ReleaseVerifyBetaArgs): string { "tagName,isPrerelease,url", ]); const release = parseJson(raw, "gh release view"); - if (!isRecord(release)) { + if (!isJsonRecord(release)) { throw new Error("GitHub release returned an unsupported JSON shape."); } if (release.tagName !== params.tag) { @@ -609,22 +612,22 @@ function verifyWorkflowRun(params: { "workflowName,headBranch,event,status,conclusion,url,createdAt,updatedAt,jobs", ]); const run = parseJson(raw, `gh run view ${params.id}`); - if (!isRecord(run)) { + if (!isJsonRecord(run)) { throw new Error(`${params.label}: workflow run returned an unsupported JSON shape.`); } - const workflowName = readString(run.workflowName); + const workflowName = normalizeOptionalText(run.workflowName); if (workflowName !== params.expectedWorkflowName) { throw new Error( `${params.label}: run ${params.id} workflow is ${workflowName ?? ""}, expected ${params.expectedWorkflowName}.`, ); } - const event = readString(run.event); + const event = normalizeOptionalText(run.event); if (event !== "workflow_dispatch") { throw new Error( `${params.label}: run ${params.id} event is ${event ?? ""}, expected workflow_dispatch.`, ); } - const headBranch = readString(run.headBranch); + const headBranch = normalizeOptionalText(run.headBranch); const allowedHeadBranches = params.allowedHeadBranches ?? (params.expectedHeadBranch !== undefined ? [params.expectedHeadBranch] : []); @@ -633,11 +636,11 @@ function verifyWorkflowRun(params: { `${params.label}: run ${params.id} branch is ${headBranch ?? ""}, expected ${allowedHeadBranches.join(" or ")}.`, ); } - const status = readString(run.status); - const conclusion = readString(run.conclusion); - const jobs = Array.isArray(run.jobs) ? run.jobs.filter(isRecord) : []; + const status = normalizeOptionalText(run.status); + const conclusion = normalizeOptionalText(run.conclusion); + const jobs = Array.isArray(run.jobs) ? run.jobs.filter(isJsonRecord) : []; const failedJobs = jobs.filter((job) => { - const jobConclusion = readString(job.conclusion); + const jobConclusion = normalizeOptionalText(job.conclusion); return ( jobConclusion !== undefined && jobConclusion !== "success" && jobConclusion !== "skipped" ); @@ -649,13 +652,15 @@ function verifyWorkflowRun(params: { ); } if (status !== "completed" || conclusion !== "success" || failedJobs.length > 0) { - const failedNames = failedJobs.map((job) => readString(job.name) ?? "").join(", "); + const failedNames = failedJobs + .map((job) => normalizeOptionalText(job.name) ?? "") + .join(", "); throw new Error( `${params.label}: run ${params.id} is ${status ?? ""}/${conclusion ?? ""}${failedNames ? `; failed jobs: ${failedNames}` : ""}.`, ); } - const createdAt = readString(run.createdAt); - const updatedAt = readString(run.updatedAt); + const createdAt = normalizeOptionalText(run.createdAt); + const updatedAt = normalizeOptionalText(run.updatedAt); const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt); const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt); const durationSeconds = @@ -665,7 +670,7 @@ function verifyWorkflowRun(params: { return { id: params.id, label: params.label, - url: readString(run.url), + url: normalizeOptionalText(run.url), durationSeconds, }; } @@ -674,7 +679,7 @@ function requirePositiveIntegerString(value: unknown, label: string): string { if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) { return String(value); } - const stringValue = readString(value); + const stringValue = normalizeOptionalText(value); if (stringValue === undefined || !POSITIVE_INTEGER_PATTERN.test(stringValue)) { throw new Error(`${label} must be a positive integer.`); } @@ -733,7 +738,7 @@ function requireArtifactWorkflowRun( throw new Error(`${params.label} is expired or missing its immutable state.`); } const workflowRun = artifact.workflow_run; - if (!isRecord(workflowRun)) { + if (!isJsonRecord(workflowRun)) { throw new Error(`${params.label} is missing workflow_run metadata.`); } if ( @@ -762,7 +767,7 @@ function requireClawHubBootstrapRunBinding( terminalRunAttempt: string; workflowPath: string; } { - if (!isRecord(run)) { + if (!isJsonRecord(run)) { throw new Error("Plugin ClawHub New run metadata is invalid."); } const runId = requirePositiveSafeInteger(run.id, "Plugin ClawHub New run id"); @@ -809,7 +814,7 @@ function requireClawHubReadbackArtifactBinding( artifactName: string; artifactSizeBytes: number; } { - if (!isRecord(artifact)) { + if (!isJsonRecord(artifact)) { throw new Error("Plugin ClawHub New readback artifact metadata is invalid."); } const artifactName = `clawhub-bootstrap-readback-${run.runId}-${run.terminalRunAttempt}`; @@ -839,7 +844,7 @@ function validateBootstrapPackageEvidence( value: unknown, params: { packageName: string; version: string }, ): void { - if (!isRecord(value)) { + if (!isJsonRecord(value)) { throw new Error(`${params.packageName} bootstrap evidence is invalid.`); } if ( @@ -876,7 +881,7 @@ function validateBootstrapPackageEvidence( const npmIntegrity = requireString(value.npmIntegrity, `${params.packageName} npm integrity`); const npmShasum = requireString(value.npmShasum, `${params.packageName} npm shasum`); const metadata = value.artifactMetadata; - if (!isRecord(metadata)) { + if (!isJsonRecord(metadata)) { throw new Error(`${params.packageName} artifact metadata evidence is invalid.`); } if (metadata.kind !== "npm-pack") { @@ -930,7 +935,7 @@ export function validateClawHubBootstrapEvidence(params: { throw new Error("Downloaded Plugin ClawHub New readback artifact digest mismatch."); } - if (!isRecord(params.evidence)) { + if (!isJsonRecord(params.evidence)) { throw new Error("Plugin ClawHub New readback evidence is invalid."); } if (params.evidence.schemaVersion !== 2 || params.evidence.verificationMode !== "postpublish") { @@ -991,7 +996,7 @@ export function validateClawHubBootstrapEvidence(params: { } const evidencePackages = params.evidence.packages .map((entry) => - isRecord(entry) ? requireString(entry.packageName, "bootstrap package name") : "", + isJsonRecord(entry) ? requireString(entry.packageName, "bootstrap package name") : "", ) .toSorted(compareCodeUnits); if (JSON.stringify(evidencePackages) !== JSON.stringify(expectedPackages)) { @@ -1000,13 +1005,13 @@ export function validateClawHubBootstrapEvidence(params: { for (const packageName of expectedPackages) { validateBootstrapPackageEvidence( params.evidence.packages.find( - (entry) => isRecord(entry) && entry.packageName === packageName, + (entry) => isJsonRecord(entry) && entry.packageName === packageName, ), { packageName, version: params.expectedVersion }, ); } - if (!isRecord(params.packageArtifact)) { + if (!isJsonRecord(params.packageArtifact)) { throw new Error("Plugin ClawHub New package artifact metadata is invalid."); } const packageArtifactId = requirePositiveIntegerString( @@ -1053,14 +1058,14 @@ export function validateClawHubBootstrapEvidence(params: { ); } - const createdAt = readString(runBinding.run.created_at); - const updatedAt = readString(runBinding.run.updated_at); + const createdAt = normalizeOptionalText(runBinding.run.created_at); + const updatedAt = normalizeOptionalText(runBinding.run.updated_at); const createdMs = createdAt === undefined ? Number.NaN : Date.parse(createdAt); const updatedMs = updatedAt === undefined ? Number.NaN : Date.parse(updatedAt); return { id: runId, label: "Plugin ClawHub New", - url: readString(runBinding.run.html_url), + url: normalizeOptionalText(runBinding.run.html_url), durationSeconds: Number.isFinite(createdMs) && Number.isFinite(updatedMs) ? Math.max(0, Math.round((updatedMs - createdMs) / 1000)) @@ -1186,13 +1191,13 @@ async function verifyClawHubBootstrapRun(params: { `actions/runs/${params.runId}/artifacts?per_page=100&name=${encodeURIComponent(readbackName)}`, "Plugin ClawHub New readback artifact list", ); - if (!isRecord(artifactList) || !Array.isArray(artifactList.artifacts)) { + if (!isJsonRecord(artifactList) || !Array.isArray(artifactList.artifacts)) { throw new Error("Plugin ClawHub New readback artifact list is invalid."); } const readbackArtifacts = artifactList.artifacts.filter( - (artifact) => isRecord(artifact) && artifact.name === readbackName, + (artifact) => isJsonRecord(artifact) && artifact.name === readbackName, ); - if (readbackArtifacts.length !== 1 || !isRecord(readbackArtifacts[0])) { + if (readbackArtifacts.length !== 1 || !isJsonRecord(readbackArtifacts[0])) { throw new Error( `Plugin ClawHub New run must have exactly one ${readbackName} artifact; found ${readbackArtifacts.length}.`, ); @@ -1205,7 +1210,7 @@ async function verifyClawHubBootstrapRun(params: { readbackArtifact, token: readGitHubToken(), }); - if (!isRecord(downloaded.value)) { + if (!isJsonRecord(downloaded.value)) { throw new Error("Plugin ClawHub New readback evidence is invalid."); } const packageArtifactId = requirePositiveIntegerString( @@ -1236,7 +1241,7 @@ function readRootPackageVersion(rootDir: string): string { readFileSync(resolve(rootDir, "package.json"), "utf8"), "package.json", ); - if (!isRecord(packageJson)) { + if (!isJsonRecord(packageJson)) { throw new Error("package.json returned an unsupported JSON shape."); } return requireString(packageJson.version, "package.json version"); diff --git a/scripts/lib/windows-cmd-helpers-runtime.mts b/scripts/lib/windows-cmd-helpers-runtime.mts index e72a5f177016..228e3de45463 100644 --- a/scripts/lib/windows-cmd-helpers-runtime.mts +++ b/scripts/lib/windows-cmd-helpers-runtime.mts @@ -2,12 +2,12 @@ const runtimeSpecifier = "../windows-cmd-helpers.mjs"; const runtime: unknown = await import(runtimeSpecifier); -function isRecord(value: unknown): value is Record { +function isRuntimeRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } function runtimeFunction(name: string): (...args: unknown[]) => unknown { - if (!isRecord(runtime) || !(name in runtime)) { + if (!isRuntimeRecord(runtime) || !(name in runtime)) { throw new Error(`windows command helper is missing ${name}`); } const value = runtime[name]; diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index b90dec0a2459..f1fdc8b16f46 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -267,7 +267,9 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +1: channel-account-bound native approval request selection. // +6: required-capability V2 harness contracts through the focused and runtime barrels, // including the side-question compatibility split. - 4863, + // +1: add the account-aware native approval request selector. + // +3: add canonical coercion exports while retaining the shipped asString compatibility name. + 4866, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -327,7 +329,9 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env // +7: restore still-existing deprecated inbound-dispatch callable re-exports. // -3: keep the generic plugin-command reply carrier opaque and non-callable. // +1: channel-account-bound native approval request selection. - 2919, + // +1: add the account-aware native approval request selector. + // +3: add canonical coercion exports while retaining the shipped asString compatibility name. + 2922, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/resolve-openclaw-package-candidate.mts b/scripts/resolve-openclaw-package-candidate.mts index 762dcfa9fb19..cd1131d5e728 100644 --- a/scripts/resolve-openclaw-package-candidate.mts +++ b/scripts/resolve-openclaw-package-candidate.mts @@ -20,6 +20,10 @@ import { fileURLToPath } from "node:url"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts"; import { resolveNpmJsonEntries } from "./lib/npm-json-output.mts"; import { resolveRepoRoot } from "./lib/repo-root.mjs"; + +function isJsonRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} import { resolveWindowsTaskkillPath } from "./lib/windows-taskkill.mjs"; import { resolveNpmRunner } from "./npm-runner.mts"; import { createPrepublishPluginRegistryArtifact } from "./prepublish-plugin-registry-artifact.mjs"; @@ -673,7 +677,7 @@ export async function readArtifactPackageCandidateMetadata(dir: string) { throw error; } const parsed: unknown = JSON.parse(raw); - if (!isRecord(parsed)) { + if (!isJsonRecord(parsed)) { throw new Error(`artifact package-candidate.json must contain a JSON object`); } const packageSourceSha = @@ -825,7 +829,7 @@ async function moveNewestPackedTarball(outputDir: string, packOutput: string, ou if (parsed !== undefined) { const packedEntry = resolveNpmJsonEntries(parsed).find( (entry): entry is Record => - isRecord(entry) && typeof entry.filename === "string", + isJsonRecord(entry) && typeof entry.filename === "string", ); const packedFilename = packedEntry && typeof packedEntry.filename === "string" ? packedEntry.filename : ""; @@ -1138,7 +1142,7 @@ function normalizeTrustedPackageSource(id: string, raw: unknown): TrustedPackage if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(id)) { throw new Error(`Invalid trusted package source id: ${id}`); } - if (!isRecord(raw)) { + if (!isJsonRecord(raw)) { throw new Error(`trusted package source ${id} must be an object`); } const hosts = toUniqueNormalizedHostList(raw.hosts, "hosts", id); @@ -1148,7 +1152,7 @@ function normalizeTrustedPackageSource(id: string, raw: unknown): TrustedPackage const rawAuth = raw.auth; let auth: TrustedPackageSource["auth"]; if (rawAuth !== undefined) { - if (!isRecord(rawAuth) || rawAuth.type !== "bearer") { + if (!isJsonRecord(rawAuth) || rawAuth.type !== "bearer") { throw new Error(`trusted package source ${id} auth must be {"type":"bearer"}`); } const authKeys = Object.keys(rawAuth); @@ -1185,11 +1189,11 @@ export async function loadTrustedPackageSource( cause: error, }); } - if (!isRecord(policy) || policy.schemaVersion !== 1) { + if (!isJsonRecord(policy) || policy.schemaVersion !== 1) { throw new Error(`Trusted package source policy must use schemaVersion 1: ${policyPath}`); } const sources = policy.sources; - if (!isRecord(sources)) { + if (!isJsonRecord(sources)) { throw new Error(`Trusted package source policy must define sources: ${policyPath}`); } if (!Object.hasOwn(sources, sourceId)) { @@ -1282,8 +1286,8 @@ function normalizeLookupResults(results: unknown): LookupAddress[] { const entries = Array.isArray(results) ? results : [results]; return entries .map((entry) => ({ - address: isRecord(entry) && typeof entry.address === "string" ? entry.address : "", - family: Number(isRecord(entry) ? (entry.family ?? 0) : 0), + address: isJsonRecord(entry) && typeof entry.address === "string" ? entry.address : "", + family: Number(isJsonRecord(entry) ? (entry.family ?? 0) : 0), })) .filter((entry) => entry.address && (entry.family === 4 || entry.family === 6)); } @@ -1659,8 +1663,8 @@ async function readPackageJson(tarball: string) { const raw = await run("tar", ["-xOf", tarball, "package/package.json"], { capture: true }); const pkg: unknown = JSON.parse(raw); return { - name: isRecord(pkg) && typeof pkg.name === "string" ? pkg.name : "", - version: isRecord(pkg) && typeof pkg.version === "string" ? pkg.version : "", + name: isJsonRecord(pkg) && typeof pkg.name === "string" ? pkg.name : "", + version: isJsonRecord(pkg) && typeof pkg.version === "string" ? pkg.version : "", }; } @@ -1676,7 +1680,7 @@ export async function readPackageBuildSourceSha(tarball: string) { } const buildInfo: unknown = JSON.parse(raw); const commit = - isRecord(buildInfo) && typeof buildInfo.commit === "string" ? buildInfo.commit.trim() : ""; + isJsonRecord(buildInfo) && typeof buildInfo.commit === "string" ? buildInfo.commit.trim() : ""; return /^[0-9a-f]{40}$/iu.test(commit) ? commit.toLowerCase() : ""; } @@ -1911,10 +1915,6 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me }); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function errorCode(value: unknown) { return isPropertyContainer(value) ? value.code : undefined; } diff --git a/scripts/verify-pr-hosted-gates.mts b/scripts/verify-pr-hosted-gates.mts index 447e594cbd43..f66bf77b574a 100644 --- a/scripts/verify-pr-hosted-gates.mts +++ b/scripts/verify-pr-hosted-gates.mts @@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { isRecord, readStringField } from "@openclaw/normalization-core/record-coerce"; import { minimatch } from "minimatch"; import { parse } from "yaml"; import { booleanFlag, parseFlagArgs, stringFlag } from "./lib/arg-utils.mts"; @@ -80,11 +80,6 @@ type HostedGateEvidence = { notApplicableWorkflows?: string[]; }; -function optionalString(record: Record, key: string): string | undefined { - const value = record[key]; - return typeof value === "string" ? value : undefined; -} - function optionalNumber(record: Record, key: string): number | undefined { const value = record[key]; return typeof value === "number" ? value : undefined; @@ -100,7 +95,7 @@ function optionalNullableString( function toWorkflowRun(run: Record) { const headRepository = isRecord(run.head_repository) - ? { full_name: optionalString(run.head_repository, "full_name") } + ? { full_name: readStringField(run.head_repository, "full_name") } : undefined; const pullRequests = Array.isArray(run.pull_requests) ? run.pull_requests.filter(isRecord).map((pullRequest) => ({ @@ -111,30 +106,30 @@ function toWorkflowRun(run: Record) { id: optionalNumber(run, "id"), run_number: optionalNumber(run, "run_number"), run_attempt: optionalNumber(run, "run_attempt"), - name: optionalString(run, "name"), - event: optionalString(run, "event"), - head_sha: optionalString(run, "head_sha"), - head_branch: optionalString(run, "head_branch"), + name: readStringField(run, "name"), + event: readStringField(run, "event"), + head_sha: readStringField(run, "head_sha"), + head_branch: readStringField(run, "head_branch"), head_repository: headRepository, - path: optionalString(run, "path"), - display_title: optionalString(run, "display_title"), - status: optionalString(run, "status"), + path: readStringField(run, "path"), + display_title: readStringField(run, "display_title"), + status: readStringField(run, "status"), conclusion: optionalNullableString(run, "conclusion"), - created_at: optionalString(run, "created_at"), - updated_at: optionalString(run, "updated_at"), - html_url: optionalString(run, "html_url"), + created_at: readStringField(run, "created_at"), + updated_at: readStringField(run, "updated_at"), + html_url: readStringField(run, "html_url"), pull_requests: pullRequests, }; } function toCiGateJob(job: Record) { return { - name: optionalString(job, "name"), + name: readStringField(job, "name"), run_id: optionalNumber(job, "run_id"), run_attempt: optionalNumber(job, "run_attempt"), - status: optionalString(job, "status"), - conclusion: optionalString(job, "conclusion"), - completed_at: optionalString(job, "completed_at"), + status: readStringField(job, "status"), + conclusion: readStringField(job, "conclusion"), + completed_at: readStringField(job, "completed_at"), }; } @@ -962,17 +957,17 @@ function loadCiReuseCandidateRuns(repo: string, headBranch: string) { return runs.map( (run): WorkflowRun => ({ id: optionalNumber(run, "databaseId"), - name: optionalString(run, "workflowName"), - event: optionalString(run, "event"), - status: optionalString(run, "status"), + name: readStringField(run, "workflowName"), + event: readStringField(run, "event"), + status: readStringField(run, "status"), conclusion: optionalNullableString(run, "conclusion"), - head_sha: optionalString(run, "headSha"), - head_branch: optionalString(run, "headBranch"), + head_sha: readStringField(run, "headSha"), + head_branch: readStringField(run, "headBranch"), path: CI_WORKFLOW_PATH, - created_at: optionalString(run, "createdAt"), - updated_at: optionalString(run, "updatedAt"), - html_url: optionalString(run, "url"), - display_title: optionalString(run, "displayTitle"), + created_at: readStringField(run, "createdAt"), + updated_at: readStringField(run, "updatedAt"), + html_url: readStringField(run, "url"), + display_title: readStringField(run, "displayTitle"), }), ); } @@ -1114,10 +1109,10 @@ function main(argv = process.argv.slice(2)) { const head = isRecord(pullRequest) && isRecord(pullRequest.head) ? pullRequest.head : undefined; const base = isRecord(pullRequest) && isRecord(pullRequest.base) ? pullRequest.base : undefined; const headRepo = head && isRecord(head.repo) ? head.repo : undefined; - const headBranch = head ? optionalString(head, "ref") : undefined; - const headRepository = headRepo ? optionalString(headRepo, "full_name") : undefined; - const baseSha = base ? optionalString(base, "sha") : undefined; - const headSha = head ? optionalString(head, "sha") : undefined; + const headBranch = head ? readStringField(head, "ref") : undefined; + const headRepository = headRepo ? readStringField(headRepo, "full_name") : undefined; + const baseSha = base ? readStringField(base, "sha") : undefined; + const headSha = head ? readStringField(head, "sha") : undefined; if (!headBranch || !headRepository || !baseSha || !headSha) { throw new Error(`PR #${args.pr} is missing head or base metadata.`); } diff --git a/src/acp/session-mapper.ts b/src/acp/session-mapper.ts index 9a58817331c2..13055af2ef4b 100644 --- a/src/acp/session-mapper.ts +++ b/src/acp/session-mapper.ts @@ -1,5 +1,5 @@ /** Resolves ACP request metadata into OpenClaw Gateway session keys and reset behavior. */ -import { readBool, readString } from "@openclaw/acp-core/meta"; +import { readBool, readMetadataString } from "@openclaw/acp-core/meta"; import type { AcpServerOptions } from "@openclaw/acp-core/types"; import type { GatewayClient } from "../gateway/client.js"; @@ -18,8 +18,8 @@ export function parseSessionMeta(meta: unknown): AcpSessionMeta { } const record = meta as Record; return { - sessionKey: readString(record, ["sessionKey", "session", "key"]), - sessionLabel: readString(record, ["sessionLabel", "label"]), + sessionKey: readMetadataString(record, ["sessionKey", "session", "key"]), + sessionLabel: readMetadataString(record, ["sessionLabel", "label"]), resetSession: readBool(record, ["resetSession", "reset"]), requireExisting: readBool(record, ["requireExistingSession", "requireExisting"]), prefixCwd: readBool(record, ["prefixCwd"]), diff --git a/src/acp/translator.prompt-stream.ts b/src/acp/translator.prompt-stream.ts index 6ca3f585a39f..6a53e622f23f 100644 --- a/src/acp/translator.prompt-stream.ts +++ b/src/acp/translator.prompt-stream.ts @@ -8,7 +8,7 @@ import type { PromptResponse, StopReason, } from "@agentclientprotocol/sdk"; -import { readBool, readNonNegativeInteger, readString } from "@openclaw/acp-core/meta"; +import { readBool, readMetadataString, readNonNegativeInteger } from "@openclaw/acp-core/meta"; import type { AcpSessionStore } from "@openclaw/acp-core/session"; import type { AcpServerOptions } from "@openclaw/acp-core/types"; import { normalizeLowercaseStringOrEmpty as normalizedChatSendAckStatus } from "@openclaw/normalization-core/string-coerce"; @@ -192,7 +192,7 @@ export class AcpTranslatorPromptStream { message, attachments: attachments.length > 0 ? attachments : undefined, idempotencyKey: runId, - thinking: readString(params["_meta"], ["thinking", "thinkingLevel"]), + thinking: readMetadataString(params["_meta"], ["thinking", "thinkingLevel"]), deliver: readBool(params["_meta"], ["deliver"]), timeoutMs: readNonNegativeInteger(params["_meta"], ["timeoutMs"]), }; diff --git a/src/acp/translator.session-list.ts b/src/acp/translator.session-list.ts index a81fc47fbde8..2ae38d07fc23 100644 --- a/src/acp/translator.session-list.ts +++ b/src/acp/translator.session-list.ts @@ -1,6 +1,6 @@ /** Cursor and pagination helpers for ACP session/list requests. */ import path from "node:path"; -import { readNumber } from "@openclaw/acp-core/meta"; +import { readMetadataNumber } from "@openclaw/acp-core/meta"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; const ACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE = 100; @@ -74,7 +74,7 @@ export function assertAbsoluteCwd(cwd: string, method: string): void { export function resolveListSessionsPageSize( meta: Record | null | undefined, ): number { - const requested = readNumber(meta, ["limit", "pageSize"]); + const requested = readMetadataNumber(meta, ["limit", "pageSize"]); if (requested === undefined) { return ACP_LIST_SESSIONS_DEFAULT_PAGE_SIZE; } diff --git a/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts index 68e99313adb3..304ac01b9bd9 100644 --- a/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.model-diagnostic-events.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; // Coverage for model-call diagnostic events around attempt stream functions. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; @@ -111,10 +112,6 @@ async function drain(stream: AsyncIterable): Promise { } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function readRecordField(record: Record, key: string, label: string) { diff --git a/src/agents/embedded-agent-runner/thinking.test.ts b/src/agents/embedded-agent-runner/thinking.test.ts index d03168ae9f55..3f1a4a6041ac 100644 --- a/src/agents/embedded-agent-runner/thinking.test.ts +++ b/src/agents/embedded-agent-runner/thinking.test.ts @@ -1276,5 +1276,26 @@ describe("stripStaleThinkingSignaturesForCompactionReplay", () => { // Same millisecond as compaction: treated as post-compaction; signature preserved expect(result).toBe(messages); }); + + it("parses numeric-looking compaction strings as dates before numeric message timestamps", () => { + const messages: AgentMessage[] = [ + castAgentMessage({ + role: "compactionSummary", + summary: "s", + tokensBefore: 0, + timestamp: "2026", + }), + castAgentMessage({ + role: "assistant", + content: [{ type: "thinking", thinking: "old", thinkingSignature: "stale_sig" }], + timestamp: 1_000_000, + }), + ]; + + const result = stripStaleThinkingSignaturesForCompactionReplay(messages); + expect((result[1] as AssistantMessage).content).toEqual([ + { type: "thinking", thinking: "old" }, + ]); + }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index b415634a9194..42f86e46a838 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -433,7 +433,7 @@ function readField(value: unknown, key: string): unknown { return (value as Record)[key]; } -function readStringField(value: unknown, key: string): string | undefined { +function readErrorStringField(value: unknown, key: string): string | undefined { const field = readField(value, key); return typeof field === "string" ? field : undefined; } @@ -452,17 +452,17 @@ function readMissingToolResultMarker(err: unknown): true | undefined { return true; } for (const key of ["code", "reason", "status"] as const) { - const value = readStringField(err, key); + const value = readErrorStringField(err, key); if (value && isMissingToolResultMarker(value)) { return true; } } - const output = readStringField(err, "output"); + const output = readErrorStringField(err, "output"); if (output && isMissingToolResultMessage(output)) { return true; } - const resultReason = readStringField(readField(err, "result"), "reason"); - const detailReason = readStringField(readField(err, "detail"), "reason"); + const resultReason = readErrorStringField(readField(err, "result"), "reason"); + const detailReason = readErrorStringField(readField(err, "detail"), "reason"); if (resultReason === MISSING_TOOL_RESULT_REASON || detailReason === MISSING_TOOL_RESULT_REASON) { return true; } diff --git a/src/agents/harness/native-hook-relay-codec.ts b/src/agents/harness/native-hook-relay-codec.ts index 5650008c144a..efe3a878fae9 100644 --- a/src/agents/harness/native-hook-relay-codec.ts +++ b/src/agents/harness/native-hook-relay-codec.ts @@ -12,7 +12,7 @@ import type { import { isJsonObject, readOptionalBoolean, - readOptionalString, + readOptionalNonEmptyString, shellQuoteArgs, } from "./native-hook-relay-utils.js"; @@ -82,27 +82,27 @@ export function normalizeNativeHookInvocation(params: { function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvocationMetadata { const payload = isJsonObject(rawPayload) ? rawPayload : {}; const metadata: NativeHookRelayInvocationMetadata = {}; - const nativeEventName = readOptionalString(payload.hook_event_name); + const nativeEventName = readOptionalNonEmptyString(payload.hook_event_name); if (nativeEventName) { metadata.nativeEventName = nativeEventName; } - const cwd = readOptionalString(payload.cwd); + const cwd = readOptionalNonEmptyString(payload.cwd); if (cwd) { metadata.cwd = cwd; } - const model = readOptionalString(payload.model); + const model = readOptionalNonEmptyString(payload.model); if (model) { metadata.model = model; } - const turnId = readOptionalString(payload.turn_id); + const turnId = readOptionalNonEmptyString(payload.turn_id); if (turnId) { metadata.turnId = turnId; } - const transcriptPath = readOptionalString(payload.transcript_path); + const transcriptPath = readOptionalNonEmptyString(payload.transcript_path); if (transcriptPath) { metadata.transcriptPath = transcriptPath; } - const permissionMode = readOptionalString(payload.permission_mode); + const permissionMode = readOptionalNonEmptyString(payload.permission_mode); if (permissionMode) { metadata.permissionMode = permissionMode; } @@ -110,15 +110,15 @@ function normalizeCodexHookMetadata(rawPayload: JsonValue): NativeHookRelayInvoc if (stopHookActive !== undefined) { metadata.stopHookActive = stopHookActive; } - const lastAssistantMessage = readOptionalString(payload.last_assistant_message); + const lastAssistantMessage = readOptionalNonEmptyString(payload.last_assistant_message); if (lastAssistantMessage) { metadata.lastAssistantMessage = lastAssistantMessage; } - const toolName = readOptionalString(payload.tool_name); + const toolName = readOptionalNonEmptyString(payload.tool_name); if (toolName) { metadata.toolName = toolName; } - const toolUseId = readOptionalString(payload.tool_use_id); + const toolUseId = readOptionalNonEmptyString(payload.tool_use_id); if (toolUseId) { metadata.toolUseId = toolUseId; } @@ -129,7 +129,7 @@ function readCodexToolInput(rawPayload: JsonValue): Record { const payload = isJsonObject(rawPayload) ? rawPayload : {}; const toolInput = payload.tool_input; if (isJsonObject(toolInput)) { - const toolName = readOptionalString(payload.tool_name); + const toolName = readOptionalNonEmptyString(payload.tool_name); return normalizeCodexToolInput( normalizeNativeHookToolName(toolName), toolInput as Record, diff --git a/src/agents/harness/native-hook-relay-permissions.ts b/src/agents/harness/native-hook-relay-permissions.ts index 0790634e2759..55355c5e4450 100644 --- a/src/agents/harness/native-hook-relay-permissions.ts +++ b/src/agents/harness/native-hook-relay-permissions.ts @@ -36,7 +36,7 @@ import type { NativeHookRelayProviderAdapter, NativeHookRelayRegistration, } from "./native-hook-relay-types.js"; -import { readOptionalString, truncateRelayText } from "./native-hook-relay-utils.js"; +import { readOptionalNonEmptyString, truncateRelayText } from "./native-hook-relay-utils.js"; export type NativeHookRelayDeferredToolApprovalRequester = typeof requestDeferredPluginToolApproval; @@ -280,7 +280,7 @@ function nativeHookRelayPermissionAllowAlwaysKey(params: { } function permissionRequestFallbackKey(request: NativeHookRelayPermissionApprovalRequest): string { - const command = readOptionalString(request.toolInput.command); + const command = readOptionalNonEmptyString(request.toolInput.command); if (command) { return `${request.toolName}:command:${truncateRelayText(command, 240)}`; } @@ -590,7 +590,7 @@ function formatPermissionApprovalDescription( } function formatToolInputPreview(toolInput: Record): string | undefined { - const command = readOptionalString(toolInput.command); + const command = readOptionalNonEmptyString(toolInput.command); if (command) { return `Command: ${truncateRelayText(sanitizeApprovalText(command), 240)}`; } diff --git a/src/agents/harness/native-hook-relay-utils.ts b/src/agents/harness/native-hook-relay-utils.ts index a44eba1790a0..e1934ce1094a 100644 --- a/src/agents/harness/native-hook-relay-utils.ts +++ b/src/agents/harness/native-hook-relay-utils.ts @@ -66,7 +66,7 @@ export function readNonEmptyString(value: unknown, name: string): string { throw new Error(`native hook relay ${name} is required`); } -export function readOptionalString(value: unknown): string | undefined { +export function readOptionalNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } diff --git a/src/agents/harness/native-hook-relay.test.ts b/src/agents/harness/native-hook-relay.test.ts index 21b8e1972a21..b4e318e7e1c5 100644 --- a/src/agents/harness/native-hook-relay.test.ts +++ b/src/agents/harness/native-hook-relay.test.ts @@ -4,6 +4,7 @@ import { createServer, request as httpRequest } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; // Covers native hook relay registration, bridge invocation, and approval state. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions.js"; @@ -43,10 +44,6 @@ afterEach(() => { testing.clearNativeHookRelaysForTests(); }); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function readRecordField(record: Record, key: string, label: string) { diff --git a/src/agents/openai-strict-tool-setting.ts b/src/agents/openai-strict-tool-setting.ts index e301393d0836..3faac2b65844 100644 --- a/src/agents/openai-strict-tool-setting.ts +++ b/src/agents/openai-strict-tool-setting.ts @@ -19,19 +19,17 @@ type OpenAIStrictToolModel = { compat?: unknown; }; -const optionalString = readStringValue; - function resolvesToNativeOpenAIStrictTools( model: OpenAIStrictToolModel, transport: OpenAITransportKind, ): boolean { const capabilities = resolveProviderRequestCapabilities({ - provider: optionalString(model.provider), - api: optionalString(model.api), - baseUrl: optionalString(model.baseUrl), + provider: readStringValue(model.provider), + api: readStringValue(model.api), + baseUrl: readStringValue(model.baseUrl), capability: "llm", transport, - modelId: optionalString(model.id), + modelId: readStringValue(model.id), compat: model.compat, }); if (!capabilities.usesKnownNativeOpenAIRoute) { diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index 09ff6aaae263..aa5f1095086e 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -318,10 +318,6 @@ export function hasSameLifecycleInput( ); } -export function toError(error: unknown): Error { - return toPreparedModelRuntimeError(error); -} - export function createPreparedModelRuntimeReplacement(): PreparedModelRuntimeReplacement { let resolve!: () => void; let reject!: (error: Error) => void; @@ -499,7 +495,7 @@ export async function publishPreparedModelRuntimeOwnerBatch(params: { } break; } catch (error) { - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); const lostCandidate = attempt.some((candidate) => !candidate.isCurrent()); if ( !(refreshError instanceof PreparedModelRuntimePublicationSupersededError) || @@ -528,7 +524,7 @@ export async function publishPreparedModelRuntimeOwnerBatch(params: { candidate.owner.needsRefresh = false; } } catch (error) { - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); for (const candidate of candidates) { if (!candidate.isCurrent()) { continue; @@ -606,7 +602,7 @@ export async function publishModelRuntimeSnapshot( owner.needsRefresh = false; return result.snapshot; } catch (error) { - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); if (owner.generation === generation && owners.get(key) === owner) { owner.pending = undefined; owner.needsRefresh = true; diff --git a/src/agents/prepared-model-runtime.ts b/src/agents/prepared-model-runtime.ts index 0319cf5838e2..5ff7c04a30a2 100644 --- a/src/agents/prepared-model-runtime.ts +++ b/src/agents/prepared-model-runtime.ts @@ -4,6 +4,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { isReservedSystemAgentId } from "../system-agent/agent-id.js"; import { registerRuntimeAuthProfileStoreMutationListener } from "./auth-profiles/runtime-snapshots.js"; import { registerPreparedRuntimeAuthMaterializationPublisher } from "./prepared-model-runtime-materializations.js"; +import { toPreparedModelRuntimeError } from "./prepared-model-runtime.errors.js"; import { PreparedModelRuntimeOwnerNotPublishedError, PreparedModelRuntimeOwnerRetention, @@ -22,7 +23,6 @@ import { publishModelRuntimeSnapshot, rebindInputToCommittedConfiguredOwner, resolvePublishedOwner, - toError, type PreparedModelRuntimeOwner, type PreparedModelRuntimeInput, type PreparedModelRuntimePublicationOptions, @@ -513,7 +513,7 @@ export function rejectPendingPreparedModelRuntimeReplacement( return; } pendingModelRuntimeReplacement = undefined; - const replacementError = toError(error); + const replacementError = toPreparedModelRuntimeError(error); replacement.reject(replacementError); notifyPreparedModelRuntimePublication({ phase: "failed", error: replacementError }); } @@ -633,7 +633,7 @@ export function refreshPreparedModelRuntimeSnapshots( } }, (error: unknown) => { - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); if (requestEpoch === refreshRequestEpoch) { // Candidate and queued auth builds may finish independently. A failed transaction must // leave no owner from its partially published generation request-visible. @@ -739,7 +739,7 @@ function invalidateForAuthMutation(event: AuthMutationEvent): void { if (error instanceof PreparedModelRuntimePublicationSupersededError) { return; } - const refreshError = toError(error); + const refreshError = toPreparedModelRuntimeError(error); notifyPreparedModelRuntimePublication({ phase: "failed", error: refreshError }); log.warn(`auth-triggered model runtime refresh failed: ${String(refreshError)}`); }); diff --git a/src/agents/session-activity-notes.ts b/src/agents/session-activity-notes.ts index bca1aaaa370b..89b0a0241727 100644 --- a/src/agents/session-activity-notes.ts +++ b/src/agents/session-activity-notes.ts @@ -214,7 +214,7 @@ export function noteSessionActivityEvent( addActivityNote(state, "Run is wrapping up", noteMaxChars); } else if (phase === "end" || phase === "error") { const health = terminalHealthFor(event); - const error = readString(data.error); + const error = readNonBlankString(data.error); addActivityNote(state, error ? `Run ${health}: ${error}` : `Run ${health}`, noteMaxChars); const terminalReply = normalizeAgentRunTerminalReplySnapshot(data.terminalReply); state.terminalReply = terminalReply; @@ -230,7 +230,7 @@ export function noteSessionActivityEvent( if (data.phase !== "start") { return; } - const name = readString(data.name) ?? "tool"; + const name = readNonBlankString(data.name) ?? "tool"; const args = summarizeToolArgs(data.args); addActivityNote(state, args ? `Tool ${name}: ${args}` : `Tool ${name}`, noteMaxChars); return; @@ -239,9 +239,9 @@ export function noteSessionActivityEvent( if (data.phase !== "end") { return; } - const title = readString(data.title) ?? readString(data.name) ?? "command"; + const title = readNonBlankString(data.title) ?? readNonBlankString(data.name) ?? "command"; const exitCode = readFiniteNumber(data.exitCode); - const status = readString(data.status) ?? (exitCode === 0 ? "completed" : "failed"); + const status = readNonBlankString(data.status) ?? (exitCode === 0 ? "completed" : "failed"); addActivityNote( state, `${title}: ${status}${exitCode === undefined ? "" : ` (exit ${exitCode})`}`, @@ -250,9 +250,9 @@ export function noteSessionActivityEvent( return; } case "item": { - const status = readString(data.status); - const title = readString(data.title); - const itemId = readString(data.itemId) ?? title; + const status = readNonBlankString(data.status); + const title = readNonBlankString(data.title); + const itemId = readNonBlankString(data.itemId) ?? title; if (!status || !title || !itemId) { return; } @@ -285,8 +285,8 @@ export function noteSessionActivityEvent( return; } case "assistant": { - const full = readString(data.text); - const delta = readString(data.delta); + const full = readNonBlankString(data.text); + const delta = readNonBlankString(data.delta); if (full) { state.assistantRawBuffer = full; } else if (delta) { @@ -317,7 +317,7 @@ export function noteSessionActivityEvent( } addActivityNote( state, - `Waiting for approval: ${readString(data.title) ?? "user action"}`, + `Waiting for approval: ${readNonBlankString(data.title) ?? "user action"}`, noteMaxChars, ); break; @@ -327,7 +327,7 @@ export function noteSessionActivityEvent( } } -function readString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/src/agents/subagents/spawn/subagent-spawn-thinking.ts b/src/agents/subagents/spawn/subagent-spawn-thinking.ts index 2f7f9715ae8e..4cbc3f08d00c 100644 --- a/src/agents/subagents/spawn/subagent-spawn-thinking.ts +++ b/src/agents/subagents/spawn/subagent-spawn-thinking.ts @@ -3,14 +3,10 @@ * this helper to patch the child session without leaking invalid caller input. */ import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeThinkLevel } from "../../../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -function readString(value: Record, key: string): string | undefined { - const raw = value[key]; - return typeof raw === "string" && raw.trim() ? raw.trim() : undefined; -} - /** Resolves subagent thinking override and initial session patch from caller/agent config. */ export function resolveSubagentThinkingOverride(params: { cfg: OpenClawConfig; @@ -27,9 +23,9 @@ export function resolveSubagentThinkingOverride(params: { ); const defaultSubagents = asOptionalObjectRecord(params.cfg.agents?.defaults?.subagents); const resolvedThinkingDefaultRaw = - readString(requesterSubagents ?? {}, "thinking") ?? - readString(targetSubagents ?? {}, "thinking") ?? - readString(defaultSubagents ?? {}, "thinking"); + normalizeOptionalString(requesterSubagents?.thinking) ?? + normalizeOptionalString(targetSubagents?.thinking) ?? + normalizeOptionalString(defaultSubagents?.thinking); const overrideCandidateRaw = params.thinkingOverrideRaw || resolvedThinkingDefaultRaw; if (overrideCandidateRaw) { diff --git a/src/agents/test-helpers/live-cache-regression-runner.ts b/src/agents/test-helpers/live-cache-regression-runner.ts index 677b9d4b2d0a..cc05d5143e46 100644 --- a/src/agents/test-helpers/live-cache-regression-runner.ts +++ b/src/agents/test-helpers/live-cache-regression-runner.ts @@ -111,13 +111,13 @@ function extractFirstToolCall(message: AssistantMessage) { function normalizeCacheUsage(usage: AssistantMessage["usage"] | undefined): CacheUsage { const value = usage as Record | null | undefined; - const readNumber = (key: keyof CacheUsage): number | undefined => + const readUsageNumber = (key: keyof CacheUsage): number | undefined => typeof value?.[key] === "number" ? value[key] : undefined; return { - input: readNumber("input"), - output: readNumber("output"), - cacheRead: readNumber("cacheRead"), - cacheWrite: readNumber("cacheWrite"), + input: readUsageNumber("input"), + output: readUsageNumber("output"), + cacheRead: readUsageNumber("cacheRead"), + cacheWrite: readUsageNumber("cacheWrite"), }; } diff --git a/src/agents/tool-search-config.ts b/src/agents/tool-search-config.ts index 3297b07db209..45983e5c90e9 100644 --- a/src/agents/tool-search-config.ts +++ b/src/agents/tool-search-config.ts @@ -22,7 +22,7 @@ function readToolSearchConfig(config?: OpenClawConfig): Record return isRecord(toolSearch) ? toolSearch : {}; } -function readBoolean(value: unknown, fallback: boolean): boolean { +function resolveBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } @@ -57,7 +57,7 @@ export function resolveToolSearchConfig(config?: OpenClawConfig): ToolSearchConf Math.min(MAX_TOOL_SEARCH_RESULTS, readInteger(raw.maxSearchLimit, DEFAULT_MAX_SEARCH_LIMIT)), ); return { - enabled: readBoolean(raw.enabled, configured), + enabled: resolveBoolean(raw.enabled, configured), mode, codeTimeoutMs: Math.max( resolveMinCodeTimeoutMs(), diff --git a/src/agents/tools/sessions-tool.ts b/src/agents/tools/sessions-tool.ts index fa7b99b446ba..952b42192ba2 100644 --- a/src/agents/tools/sessions-tool.ts +++ b/src/agents/tools/sessions-tool.ts @@ -137,7 +137,7 @@ type SessionsToolOptions = { hasInProcessGatewayContext?: () => boolean; }; -function readBoolean(params: Record, key: string): boolean | undefined { +function readBooleanParam(params: Record, key: string): boolean | undefined { const value = params[key]; if (value === undefined) { return undefined; @@ -292,7 +292,7 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool archivedOnly: true, expectedSessionId, ...(expectedLifecycleRevision ? { expectedLifecycleRevision } : {}), - deleteTranscript: readBoolean(params, "deleteTranscript") ?? true, + deleteTranscript: readBooleanParam(params, "deleteTranscript") ?? true, }), ); } @@ -345,8 +345,10 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool ...(params.ttlMinutes !== undefined ? { ttlMinutes: readInteger(params, "ttlMinutes") } : {}), - ...(params.pinned !== undefined ? { pinned: readBoolean(params, "pinned") } : {}), - ...(params.archived !== undefined ? { archived: readBoolean(params, "archived") } : {}), + ...(params.pinned !== undefined ? { pinned: readBooleanParam(params, "pinned") } : {}), + ...(params.archived !== undefined + ? { archived: readBooleanParam(params, "archived") } + : {}), ...(params.model !== undefined ? { model: readToolStringParam(params, "model", { required: true }) } : {}), diff --git a/src/agents/tools/terminal-tool.ts b/src/agents/tools/terminal-tool.ts index fbaf0626262e..d4d7e1ecf27a 100644 --- a/src/agents/tools/terminal-tool.ts +++ b/src/agents/tools/terminal-tool.ts @@ -125,7 +125,7 @@ function readShow(params: Record): boolean { return value; } -function readOptionalString( +function readOptionalStringParam( params: Record, key: "command" | "cwd", options: { trim?: boolean } = {}, @@ -187,8 +187,8 @@ export function createTerminalTool(opts: TerminalToolOptions = {}): AnyAgentTool } if (action === "open") { - const command = readOptionalString(params, "command", { trim: false }); - const cwd = readOptionalString(params, "cwd"); + const command = readOptionalStringParam(params, "command", { trim: false }); + const cwd = readOptionalStringParam(params, "cwd"); const cols = readDimension(params, "cols", DEFAULT_COLS); const rows = readDimension(params, "rows", DEFAULT_ROWS); const show = readShow(params); diff --git a/src/auto-reply/reply/reply-media-paths.test.ts b/src/auto-reply/reply/reply-media-paths.test.ts index 86f0c346d7ef..7548ab17373c 100644 --- a/src/auto-reply/reply/reply-media-paths.test.ts +++ b/src/auto-reply/reply/reply-media-paths.test.ts @@ -31,13 +31,13 @@ type NormalizedReply = { text?: string; }; -function isRecord(value: unknown): value is Record { +function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function requireRecord(value: unknown, label: string): Record { - expect(isRecord(value)).toBe(true); - if (!isRecord(value)) { + expect(isObjectRecord(value)).toBe(true); + if (!isObjectRecord(value)) { throw new Error(`${label} was not an object`); } return value; diff --git a/src/channels/plugins/status-issues/shared.ts b/src/channels/plugins/status-issues/shared.ts index 58ad13bff4c1..086e1020d007 100644 --- a/src/channels/plugins/status-issues/shared.ts +++ b/src/channels/plugins/status-issues/shared.ts @@ -8,13 +8,6 @@ import { isRecord } from "../../../utils.js"; import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../types.public.js"; export { isRecord }; -/** - * Normalizes optional string metadata in status issue helpers. - */ -export function asString(value: unknown): string | undefined { - return typeof value === "string" ? normalizeOptionalString(value) : undefined; -} - /** * Formats optional match metadata for status issue messages. */ @@ -28,7 +21,7 @@ export function formatMatchMetadata(params: { : typeof params.matchKey === "number" ? String(params.matchKey) : undefined; - const matchSource = asString(params.matchSource); + const matchSource = normalizeOptionalString(params.matchSource); const parts = [ matchKey ? `matchKey=${matchKey}` : null, matchSource ? `matchSource=${matchSource}` : null, @@ -55,7 +48,7 @@ export function resolveEnabledConfiguredAccountId(account: { enabled?: unknown; configured?: unknown; }): string | null { - const accountId = asString(account.accountId) ?? "default"; + const accountId = normalizeOptionalString(account.accountId) ?? "default"; const enabled = account.enabled !== false; const configured = account.configured === true; return enabled && configured ? accountId : null; @@ -79,7 +72,7 @@ export function collectIssuesForEnabledAccounts< if (!account || account.enabled === false) { continue; } - const accountId = asString(account.accountId) ?? "default"; + const accountId = normalizeOptionalString(account.accountId) ?? "default"; params.collectIssues({ account, accountId, issues }); } return issues; diff --git a/src/cli/nodes-camera.ts b/src/cli/nodes-camera.ts index 6448f8329255..af403f533448 100644 --- a/src/cli/nodes-camera.ts +++ b/src/cli/nodes-camera.ts @@ -12,7 +12,7 @@ import { asBoolean, asNumber, asRecord, - asString, + readStringValue, resolveTempPathParts, } from "./nodes-media-utils.js"; import { publishOutputFileAtomically } from "./output-file.runtime.js"; @@ -85,9 +85,9 @@ type CameraClipPayload = { /** Validate and normalize an unknown camera still-image payload. */ export function parseCameraSnapPayload(value: unknown): CameraSnapPayload { const obj = asRecord(value); - const format = asString(obj.format); - const base64 = asString(obj.base64); - const url = asString(obj.url); + const format = readStringValue(obj.format); + const base64 = readStringValue(obj.base64); + const url = readStringValue(obj.url); const width = asNumber(obj.width); const height = asNumber(obj.height); if (!format || (!base64 && !url) || width === undefined || height === undefined) { @@ -99,9 +99,9 @@ export function parseCameraSnapPayload(value: unknown): CameraSnapPayload { /** Validate and normalize an unknown camera clip payload. */ export function parseCameraClipPayload(value: unknown): CameraClipPayload { const obj = asRecord(value); - const format = asString(obj.format); - const base64 = asString(obj.base64); - const url = asString(obj.url); + const format = readStringValue(obj.format); + const base64 = readStringValue(obj.base64); + const url = readStringValue(obj.url); const durationMs = asNumber(obj.durationMs); const hasAudio = asBoolean(obj.hasAudio); if (!format || (!base64 && !url) || durationMs === undefined || hasAudio === undefined) { diff --git a/src/cli/nodes-media-utils.test.ts b/src/cli/nodes-media-utils.test.ts index 2f7ec8a23a6e..b5c5639a3ed8 100644 --- a/src/cli/nodes-media-utils.test.ts +++ b/src/cli/nodes-media-utils.test.ts @@ -4,7 +4,7 @@ import { asBoolean, asNumber, asRecord, - asString, + readStringValue, mediaPathMatchesFormat, resolveTempPathParts, } from "./nodes-media-utils.js"; @@ -13,8 +13,8 @@ describe("cli/nodes-media-utils", () => { it("parses primitive helper values", () => { expect(asRecord({ a: 1 })).toEqual({ a: 1 }); expect(asRecord("x")).toStrictEqual({}); - expect(asString("x")).toBe("x"); - expect(asString(1)).toBeUndefined(); + expect(readStringValue("x")).toBe("x"); + expect(readStringValue(1)).toBeUndefined(); expect(asNumber(1)).toBe(1); expect(asNumber(Number.NaN)).toBeUndefined(); expect(asBoolean(true)).toBe(true); diff --git a/src/cli/nodes-media-utils.ts b/src/cli/nodes-media-utils.ts index 2fac48fdff05..f46177ca2a71 100644 --- a/src/cli/nodes-media-utils.ts +++ b/src/cli/nodes-media-utils.ts @@ -3,13 +3,11 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { extnameFromAnyPath } from "@openclaw/media-core/file-name"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -export { asFiniteNumber as asNumber } from "../../packages/normalization-core/src/number-coercion.js"; -import { readStringValue } from "../../packages/normalization-core/src/string-coerce.js"; -export { asRecord } from "../../packages/normalization-core/src/record-coerce.js"; +export { asFiniteNumber as asNumber } from "@openclaw/normalization-core/number-coercion"; +export { asRecord } from "@openclaw/normalization-core/record-coerce"; +export { readStringValue } from "@openclaw/normalization-core/string-coerce"; export { asBoolean } from "../utils/boolean.js"; -export const asString = readStringValue; - function normalizeMediaExtension(value: string): string | undefined { const raw = (value.startsWith(".") ? value.slice(1) : value).toLowerCase(); if (!/^[a-z0-9][a-z0-9_-]{0,15}$/u.test(raw)) { diff --git a/src/cli/nodes-screen.ts b/src/cli/nodes-screen.ts index ff023f303ccf..7efdb895a65b 100644 --- a/src/cli/nodes-screen.ts +++ b/src/cli/nodes-screen.ts @@ -2,7 +2,7 @@ import * as path from "node:path"; import { extnameFromAnyPath } from "@openclaw/media-core/file-name"; import { writeBase64ToFile } from "./nodes-camera.js"; -import { asRecord, asString, resolveTempPathParts } from "./nodes-media-utils.js"; +import { asRecord, readStringValue, resolveTempPathParts } from "./nodes-media-utils.js"; /** Validated payload returned by `nodes screen record` RPC calls. */ type ScreenRecordPayload = { @@ -17,8 +17,8 @@ type ScreenRecordPayload = { /** Validate and normalize an unknown screen-record payload. */ export function parseScreenRecordPayload(value: unknown): ScreenRecordPayload { const obj = asRecord(value); - const format = asString(obj.format); - const base64 = asString(obj.base64); + const format = readStringValue(obj.format); + const base64 = readStringValue(obj.base64); if (!format || !base64) { throw new Error("invalid screen.record payload"); } @@ -61,15 +61,15 @@ type ScreenSnapshotPayload = { /** Validate and normalize an unknown screen-snapshot payload. */ export function parseScreenSnapshotPayload(value: unknown): ScreenSnapshotPayload { const obj = asRecord(value); - const format = asString(obj.format); - const base64 = asString(obj.base64); + const format = readStringValue(obj.format); + const base64 = readStringValue(obj.base64); if (!format || !base64) { throw new Error("invalid screen.snapshot payload"); } return { format, base64, - displayFrameId: asString(obj.displayFrameId) || undefined, + displayFrameId: readStringValue(obj.displayFrameId) || undefined, screenIndex: typeof obj.screenIndex === "number" ? obj.screenIndex : undefined, width: typeof obj.width === "number" ? obj.width : undefined, height: typeof obj.height === "number" ? obj.height : undefined, diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts index 7dce03106620..68d55e2a481c 100644 --- a/src/cli/program/register.setup.ts +++ b/src/cli/program/register.setup.ts @@ -1,3 +1,4 @@ +import { readStringValue } from "@openclaw/normalization-core/string-coerce"; // Setup command registration: system-agent chat for configured systems, onboarding otherwise. import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; @@ -26,9 +27,6 @@ import { const SYSTEM_AGENT_OPTION_NAMES = new Set(["message", "yes", "json"]); const BASELINE_OPTION_NAMES = new Set(["baseline", "workspace", "json"]); -const optionalString = (value: unknown): string | undefined => - typeof value === "string" ? value : undefined; - type SetupRoute = "onboarding" | "system-agent"; export function resolveSetupCommandRoute(input: { @@ -104,7 +102,7 @@ async function runSystemAgentEntry( await import("../../commands/system-agent-with-inference.js"); await runSystemAgentWithInference( { - message: optionalString(options.message), + message: readStringValue(options.message), yes: Boolean(options.yes), json: Boolean(options.json), }, @@ -126,7 +124,7 @@ async function runOnboardingEntry( } const { setupCommand } = await import("../../commands/setup.js"); await setupCommand( - { workspace: optionalString(options.workspace), json: Boolean(options.json) }, + { workspace: readStringValue(options.workspace), json: Boolean(options.json) }, runtime, ); return; @@ -140,7 +138,7 @@ async function runOnboardingEntry( const { setupWizardCommand } = await import("../../commands/onboard.js"); await setupWizardCommand( { - workspace: optionalString(options.workspace), + workspace: readStringValue(options.workspace), nonInteractive: Boolean(options.nonInteractive), acceptRisk: Boolean(options.acceptRisk), classic: Boolean(options.classic), @@ -153,9 +151,9 @@ async function runOnboardingEntry( gatewayPort, gatewayBind: options.gatewayBind as GatewayBind | undefined, gatewayAuth: options.gatewayAuth as GatewayAuthChoice | undefined, - gatewayToken: optionalString(options.gatewayToken), - gatewayTokenRefEnv: optionalString(options.gatewayTokenRefEnv), - gatewayPassword: optionalString(options.gatewayPassword), + gatewayToken: readStringValue(options.gatewayToken), + gatewayTokenRefEnv: readStringValue(options.gatewayTokenRefEnv), + gatewayPassword: readStringValue(options.gatewayPassword), tailscale: options.tailscale as TailscaleMode | undefined, tailscaleResetOnExit, installDaemon, @@ -169,11 +167,11 @@ async function runOnboardingEntry( suppressGatewayTokenOutput: Boolean(options.suppressGatewayTokenOutput), skipHooks: Boolean(options.skipHooks), nodeManager: options.nodeManager as NodeManagerChoice | undefined, - importFrom: optionalString(options.importFrom), - importSource: optionalString(options.importSource), + importFrom: readStringValue(options.importFrom), + importSource: readStringValue(options.importSource), importSecrets: Boolean(options.importSecrets), - remoteUrl: optionalString(options.remoteUrl), - remoteToken: optionalString(options.remoteToken), + remoteUrl: readStringValue(options.remoteUrl), + remoteToken: readStringValue(options.remoteToken), json: Boolean(options.json), }, runtime, diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index a59ac7519f92..0f1ade104197 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -1,5 +1,6 @@ // Channel setup plugin install tests cover install decisions, registry reloads, scoped snapshots, and trust boundaries. import path from "node:path"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord, bundledPluginRoot, @@ -314,10 +315,6 @@ function expectPluginLoadedFromLocalPath( expect(result.cfg.plugins?.load?.paths).toContain(expectedPath); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object"); function requireArray(value: unknown, label: string): unknown[] { diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index ee66cead1bc6..6eb9f894a118 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -33,14 +33,14 @@ const noteImplicitFallbackClobberWarningsMock = vi.hoisted(() => }), ); const legacyConfigMigrationForTest = vi.hoisted(() => { - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function ensureRecord(parent: Record, key: string): Record { - const current = asRecord(parent[key]); + const current = readNullableRecord(parent[key]); if (current) { return current; } @@ -50,8 +50,8 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } function migrateThreadBinding(value: unknown, changes: string[], pathLabel: string): void { - const record = asRecord(value); - const bindings = asRecord(record?.threadBindings); + const record = readNullableRecord(value); + const bindings = readNullableRecord(record?.threadBindings); if (!bindings || !("ttlHours" in bindings)) { return; } @@ -74,7 +74,7 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { delete channel.streamMode; return true; } - const streaming = asRecord(channel.streaming) ?? {}; + const streaming = readNullableRecord(channel.streaming) ?? {}; if (!("mode" in streaming)) { streaming.mode = channel.streamMode === "block" @@ -91,8 +91,8 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { function migrateNestedAllowAliases(channel: Record, channelId: string): boolean { let changed = false; if (channelId === "slack") { - for (const room of Object.values(asRecord(channel.channels) ?? {})) { - const roomRecord = asRecord(room); + for (const room of Object.values(readNullableRecord(channel.channels) ?? {})) { + const roomRecord = readNullableRecord(room); if (roomRecord && "allow" in roomRecord) { roomRecord.enabled = roomRecord.allow; delete roomRecord.allow; @@ -101,8 +101,8 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } } if (channelId === "googlechat") { - for (const group of Object.values(asRecord(channel.groups) ?? {})) { - const groupRecord = asRecord(group); + for (const group of Object.values(readNullableRecord(channel.groups) ?? {})) { + const groupRecord = readNullableRecord(group); if (groupRecord && "allow" in groupRecord) { groupRecord.enabled = groupRecord.allow; delete groupRecord.allow; @@ -111,9 +111,11 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } } if (channelId === "discord") { - for (const guild of Object.values(asRecord(channel.guilds) ?? {})) { - for (const room of Object.values(asRecord(asRecord(guild)?.channels) ?? {})) { - const roomRecord = asRecord(room); + for (const guild of Object.values(readNullableRecord(channel.guilds) ?? {})) { + for (const room of Object.values( + readNullableRecord(readNullableRecord(guild)?.channels) ?? {}, + )) { + const roomRecord = readNullableRecord(room); if (roomRecord && "allow" in roomRecord) { roomRecord.enabled = roomRecord.allow; delete roomRecord.allow; @@ -126,14 +128,14 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } function migrate(raw: unknown): { next: Record | null; changes: string[] } { - const root = asRecord(raw); + const root = readNullableRecord(raw); if (!root) { return { next: null, changes: [] }; } const next = structuredClone(root); const changes: string[] = []; - const heartbeat = asRecord(next.heartbeat); + const heartbeat = readNullableRecord(next.heartbeat); if (heartbeat) { const agents = ensureRecord(next, "agents"); const agentDefaults = ensureRecord(agents, "defaults"); @@ -153,13 +155,13 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } if (Object.keys(agentHeartbeat).length > 0) { agentDefaults.heartbeat = { - ...asRecord(agentDefaults.heartbeat), + ...readNullableRecord(agentDefaults.heartbeat), ...agentHeartbeat, }; } if (Object.keys(channelHeartbeat).length > 0) { channelDefaults.heartbeat = { - ...asRecord(channelDefaults.heartbeat), + ...readNullableRecord(channelDefaults.heartbeat), ...channelHeartbeat, }; } @@ -167,14 +169,14 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { changes.push("Moved heartbeat to agents.defaults.heartbeat and channels.defaults.heartbeat."); } - const internalHooks = asRecord(asRecord(next.hooks)?.internal); + const internalHooks = readNullableRecord(readNullableRecord(next.hooks)?.internal); if (internalHooks && "handlers" in internalHooks) { delete internalHooks.handlers; changes.push( "Removed retired hooks.internal.handlers registrations; hook files must be migrated separately.", ); - const entries = asRecord(internalHooks.entries); - const extraDirs = asRecord(internalHooks.load)?.extraDirs; + const entries = readNullableRecord(internalHooks.entries); + const extraDirs = readNullableRecord(internalHooks.load)?.extraDirs; const hasNamedEntries = Boolean(entries && Object.keys(entries).length > 0); const hasExtraDirs = Array.isArray(extraDirs) && @@ -187,7 +189,7 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } } - const gateway = asRecord(next.gateway); + const gateway = readNullableRecord(next.gateway); if (gateway?.bind === "0.0.0.0") { gateway.bind = "lan"; changes.push("Normalized gateway.bind host alias."); @@ -197,17 +199,17 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } migrateThreadBinding(next.session, changes, "session"); - const sessionMaintenance = asRecord(asRecord(next.session)?.maintenance); + const sessionMaintenance = readNullableRecord(readNullableRecord(next.session)?.maintenance); if (sessionMaintenance && "rotateBytes" in sessionMaintenance) { delete sessionMaintenance.rotateBytes; changes.push("Removed deprecated session.maintenance.rotateBytes."); } - const channels = asRecord(next.channels); + const channels = readNullableRecord(next.channels); for (const [channelId, channelRaw] of Object.entries(channels ?? {})) { if (channelId === "defaults") { continue; } - const channel = asRecord(channelRaw); + const channel = readNullableRecord(channelRaw); if (!channel) { continue; } @@ -218,8 +220,10 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { if (migrateNestedAllowAliases(channel, channelId)) { changes.push(`Normalized channels.${channelId} nested allow aliases.`); } - for (const [accountId, accountRaw] of Object.entries(asRecord(channel.accounts) ?? {})) { - const account = asRecord(accountRaw); + for (const [accountId, accountRaw] of Object.entries( + readNullableRecord(channel.accounts) ?? {}, + )) { + const account = readNullableRecord(accountRaw); migrateThreadBinding(account, changes, `channels.${channelId}.accounts.${accountId}`); if (account && migrateStreamingAlias(account, channelId)) { changes.push(`Normalized channels.${channelId}.accounts.${accountId} streaming aliases.`); @@ -227,7 +231,9 @@ const legacyConfigMigrationForTest = vi.hoisted(() => { } } - const sandbox = asRecord(asRecord(asRecord(next.agents)?.defaults)?.sandbox); + const sandbox = readNullableRecord( + readNullableRecord(readNullableRecord(next.agents)?.defaults)?.sandbox, + ); if (sandbox && "perSession" in sandbox) { sandbox.scope = sandbox.perSession === true ? "session" : "workspace"; delete sandbox.perSession; @@ -343,7 +349,7 @@ vi.mock("../config/legacy.js", () => { requireSourceLiteral?: boolean; }; - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; @@ -352,7 +358,7 @@ vi.mock("../config/legacy.js", () => { function getPathValue(root: Record, pathParts: readonly string[]): unknown { let cursor: unknown = root; for (const part of pathParts) { - const record = asRecord(cursor); + const record = readNullableRecord(cursor); if (!record) { return undefined; } @@ -384,11 +390,11 @@ vi.mock("../config/legacy.js", () => { return { findLegacyConfigIssues: (raw: unknown, sourceRaw?: unknown, extraRules: LegacyRule[] = []) => { - const root = asRecord(raw); + const root = readNullableRecord(raw); if (!root) { return []; } - const sourceRoot = asRecord(sourceRaw) ?? root; + const sourceRoot = readNullableRecord(sourceRaw) ?? root; const issues: Array<{ path: string; message: string }> = []; if ("heartbeat" in root) { @@ -405,7 +411,7 @@ vi.mock("../config/legacy.js", () => { 'memorySearch is legacy; use memory.search. Run "openclaw doctor --fix".', ); } - const gateway = asRecord(root.gateway); + const gateway = readNullableRecord(root.gateway); if (gateway && "bind" in gateway) { addIssue( issues, @@ -413,7 +419,9 @@ vi.mock("../config/legacy.js", () => { 'gateway.bind host aliases are legacy; use the canonical bind mode. Run "openclaw doctor --fix".', ); } - const sessionThreadBindings = asRecord(asRecord(root.session)?.threadBindings); + const sessionThreadBindings = readNullableRecord( + readNullableRecord(root.session)?.threadBindings, + ); if (sessionThreadBindings && "ttlHours" in sessionThreadBindings) { addIssue( issues, @@ -421,7 +429,7 @@ vi.mock("../config/legacy.js", () => { 'session.threadBindings.ttlHours is legacy; use session.threadBindings.idleHours. Run "openclaw doctor --fix".', ); } - const sessionMaintenance = asRecord(asRecord(root.session)?.maintenance); + const sessionMaintenance = readNullableRecord(readNullableRecord(root.session)?.maintenance); if (sessionMaintenance && "rotateBytes" in sessionMaintenance) { addIssue( issues, @@ -429,7 +437,9 @@ vi.mock("../config/legacy.js", () => { 'session.maintenance.rotateBytes is deprecated and ignored; run "openclaw doctor --fix" to remove it.', ); } - const xSearch = asRecord(asRecord(asRecord(root.tools)?.web)?.x_search); + const xSearch = readNullableRecord( + readNullableRecord(readNullableRecord(root.tools)?.web)?.x_search, + ); if (xSearch && "apiKey" in xSearch) { addIssue( issues, @@ -437,7 +447,9 @@ vi.mock("../config/legacy.js", () => { 'tools.web.x_search.apiKey is legacy; use plugins.entries.xai.config.webSearch.apiKey. Run "openclaw doctor --fix".', ); } - const sandbox = asRecord(asRecord(asRecord(root.agents)?.defaults)?.sandbox); + const sandbox = readNullableRecord( + readNullableRecord(readNullableRecord(root.agents)?.defaults)?.sandbox, + ); if (sandbox && "perSession" in sandbox) { addIssue( issues, @@ -445,7 +457,7 @@ vi.mock("../config/legacy.js", () => { 'agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope. Run "openclaw doctor --fix".', ); } - const internalHooks = asRecord(asRecord(root.hooks)?.internal); + const internalHooks = readNullableRecord(readNullableRecord(root.hooks)?.internal); if (internalHooks && "handlers" in internalHooks) { addIssue( issues, @@ -454,12 +466,12 @@ vi.mock("../config/legacy.js", () => { ); } - const channels = asRecord(root.channels); + const channels = readNullableRecord(root.channels); for (const [channelId, channelRaw] of Object.entries(channels ?? {})) { if (channelId === "defaults") { continue; } - const channel = asRecord(channelRaw); + const channel = readNullableRecord(channelRaw); if (!channel) { continue; } @@ -472,7 +484,7 @@ vi.mock("../config/legacy.js", () => { : `channels.${channelId}.streamMode, channels.${channelId}.streaming aliases are legacy. Run "openclaw doctor --fix".`, ); } - const threadBindings = asRecord(channel.threadBindings); + const threadBindings = readNullableRecord(channel.threadBindings); if (threadBindings && "ttlHours" in threadBindings) { addIssue( issues, @@ -481,8 +493,8 @@ vi.mock("../config/legacy.js", () => { ); } if (channelId === "slack") { - for (const roomRaw of Object.values(asRecord(channel.channels) ?? {})) { - if ("allow" in (asRecord(roomRaw) ?? {})) { + for (const roomRaw of Object.values(readNullableRecord(channel.channels) ?? {})) { + if ("allow" in (readNullableRecord(roomRaw) ?? {})) { addIssue( issues, ["channels", "slack"], @@ -492,8 +504,8 @@ vi.mock("../config/legacy.js", () => { } } if (channelId === "googlechat") { - for (const spaceRaw of Object.values(asRecord(channel.groups) ?? {})) { - if ("allow" in (asRecord(spaceRaw) ?? {})) { + for (const spaceRaw of Object.values(readNullableRecord(channel.groups) ?? {})) { + if ("allow" in (readNullableRecord(spaceRaw) ?? {})) { addIssue( issues, ["channels", "googlechat"], @@ -503,10 +515,10 @@ vi.mock("../config/legacy.js", () => { } } if (channelId === "discord") { - for (const guildRaw of Object.values(asRecord(channel.guilds) ?? {})) { - const guild = asRecord(guildRaw); - for (const roomRaw of Object.values(asRecord(guild?.channels) ?? {})) { - if ("allow" in (asRecord(roomRaw) ?? {})) { + for (const guildRaw of Object.values(readNullableRecord(channel.guilds) ?? {})) { + const guild = readNullableRecord(guildRaw); + for (const roomRaw of Object.values(readNullableRecord(guild?.channels) ?? {})) { + if ("allow" in (readNullableRecord(roomRaw) ?? {})) { addIssue( issues, ["channels", "discord"], @@ -516,9 +528,11 @@ vi.mock("../config/legacy.js", () => { } } } - for (const [accountId, accountRaw] of Object.entries(asRecord(channel.accounts) ?? {})) { - const account = asRecord(accountRaw); - const accountThreadBindings = asRecord(account?.threadBindings); + for (const [accountId, accountRaw] of Object.entries( + readNullableRecord(channel.accounts) ?? {}, + )) { + const account = readNullableRecord(accountRaw); + const accountThreadBindings = readNullableRecord(account?.threadBindings); if (accountThreadBindings && "ttlHours" in accountThreadBindings) { addIssue( issues, @@ -854,14 +868,14 @@ vi.mock("./doctor/channel-capabilities.js", () => { }); vi.mock("../plugins/doctor-contract-registry.js", () => { - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function hasLegacyTalkFields(value: unknown): boolean { - const talk = asRecord(value); + const talk = readNullableRecord(value); return Boolean( talk && ["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) => @@ -905,7 +919,7 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { } let changed = false; - const streaming = asRecord(entry.streaming) ?? {}; + const streaming = readNullableRecord(entry.streaming) ?? {}; if (!("mode" in streaming) && ("streamMode" in entry || typeof entry.streaming !== "object")) { const mode = resolveDiscordStreamMode(entry); streaming.mode = mode; @@ -926,7 +940,7 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { changes.push(`Moved ${pathPrefix}.chunkMode → ${pathPrefix}.streaming.chunkMode.`); changed = true; } - const block = asRecord(streaming.block) ?? {}; + const block = readNullableRecord(streaming.block) ?? {}; if ("blockStreaming" in entry && !("enabled" in block)) { block.enabled = entry.blockStreaming; delete entry.blockStreaming; @@ -944,7 +958,7 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { if (Object.keys(block).length > 0) { streaming.block = block; } - const preview = asRecord(streaming.preview) ?? {}; + const preview = readNullableRecord(streaming.preview) ?? {}; if ("draftChunk" in entry && !("chunk" in preview)) { preview.chunk = entry.draftChunk; delete entry.draftChunk; @@ -962,23 +976,23 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { config: unknown; changes: string[]; } { - const root = asRecord(cfg); - const discord = asRecord(asRecord(root?.channels)?.discord); + const root = readNullableRecord(cfg); + const discord = readNullableRecord(readNullableRecord(root?.channels)?.discord); if (!root || !discord) { return { config: cfg, changes: [] }; } const next = structuredClone(root); - const nextDiscord = asRecord(asRecord(next.channels)?.discord); + const nextDiscord = readNullableRecord(readNullableRecord(next.channels)?.discord); if (!nextDiscord) { return { config: cfg, changes: [] }; } const changes: string[] = []; normalizeDiscordStreamingEntry(nextDiscord, "channels.discord", changes); - const accounts = asRecord(nextDiscord.accounts); + const accounts = readNullableRecord(nextDiscord.accounts); for (const [accountId, accountRaw] of Object.entries(accounts ?? {})) { - const account = asRecord(accountRaw); + const account = readNullableRecord(accountRaw); if (account) { normalizeDiscordStreamingEntry(account, `channels.discord.accounts.${accountId}`, changes); } @@ -989,8 +1003,8 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { return { collectRelevantDoctorPluginIds: (raw: unknown): string[] => { const ids = new Set(); - const root = asRecord(raw); - const channels = asRecord(root?.channels); + const root = readNullableRecord(raw); + const channels = readNullableRecord(root?.channels); for (const channelId of Object.keys(channels ?? {})) { if (channelId !== "defaults") { ids.add(channelId); @@ -1055,7 +1069,7 @@ vi.mock("../plugins/setup-registry.js", () => ({ })); vi.mock("./doctor/shared/channel-doctor.js", () => { - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; @@ -1076,7 +1090,7 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { "roles", ]); const visit = (value: unknown) => { - const record = asRecord(value); + const record = readNullableRecord(value); if (!record) { return; } @@ -1103,10 +1117,10 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { function collectCompatibilityMutations(cfg: { channels?: Record }) { const next = structuredClone(cfg); const changes: string[] = []; - const telegram = asRecord(next.channels?.telegram); + const telegram = readNullableRecord(next.channels?.telegram); if (telegram && "groupMentionsOnly" in telegram) { - const groups = asRecord(telegram.groups) ?? {}; - const defaultGroup = asRecord(groups["*"]) ?? {}; + const groups = readNullableRecord(telegram.groups) ?? {}; + const defaultGroup = readNullableRecord(groups["*"]) ?? {}; if (defaultGroup.requireMention === undefined) { defaultGroup.requireMention = telegram.groupMentionsOnly; } @@ -1121,20 +1135,20 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { } function collectInactiveTelegramWarnings(cfg: { channels?: Record }): string[] { - const telegram = asRecord(cfg.channels?.telegram); + const telegram = readNullableRecord(cfg.channels?.telegram); if (!telegram) { return []; } - const accounts = asRecord(telegram.accounts); + const accounts = readNullableRecord(telegram.accounts); if (!accounts) { return []; } return Object.entries(accounts).flatMap(([accountId, accountRaw]) => { - const account = asRecord(accountRaw); + const account = readNullableRecord(accountRaw); if ( !account || account.enabled !== false || - !asRecord(account.botToken) || + !readNullableRecord(account.botToken) || !hasOwnStringArray(account.allowFrom) ) { return []; @@ -1163,7 +1177,8 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { if (!botToken) { return false; } - const groups = asRecord(params.account.groups) ?? asRecord(params.parent?.groups); + const groups = + readNullableRecord(params.account.groups) ?? readNullableRecord(params.parent?.groups); const groupAllowFrom = params.account.groupAllowFrom ?? params.parent?.groupAllowFrom; return !groups && !hasOwnStringArray(groupAllowFrom); } @@ -1190,16 +1205,16 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { collectChannelDoctorEmptyAllowlistExtraWarnings: vi.fn(collectTelegramFirstTimeExtraWarnings), collectChannelDoctorMutableAllowlistWarnings: vi.fn( ({ cfg }: { cfg: { channels?: Record } }) => { - const zalouser = asRecord(cfg.channels?.zalouser); + const zalouser = readNullableRecord(cfg.channels?.zalouser); if (!zalouser || zalouser.dangerouslyAllowNameMatching === true) { return []; } - const groups = asRecord(zalouser.groups); + const groups = readNullableRecord(zalouser.groups); if (!groups) { return []; } return Object.entries(groups).flatMap(([name, group]) => - asRecord(group)?.allow === true + readNullableRecord(group)?.allow === true ? [ `- Found mutable allowlist entry across zalouser while name matching is disabled by default: channels.zalouser.groups: ${name}.`, ] @@ -1211,10 +1226,10 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { collectChannelDoctorRepairMutations: vi.fn( async ({ cfg }: { cfg: { channels?: Record } }) => { const mutations: Array<{ config: unknown; changes: string[]; warnings?: string[] }> = []; - const discord = asRecord(cfg.channels?.discord); + const discord = readNullableRecord(cfg.channels?.discord); if (discord) { const next = structuredClone(cfg); - const nextDiscord = asRecord(next.channels?.discord); + const nextDiscord = readNullableRecord(next.channels?.discord); if (nextDiscord && stringifySelectedArrays(nextDiscord)) { mutations.push({ config: next, @@ -1244,7 +1259,7 @@ vi.mock("./doctor/shared/channel-doctor.js", () => { }); vi.mock("./doctor/shared/preview-warnings.js", () => { - function asRecord(value: unknown): Record | null { + function readNullableRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; @@ -1269,7 +1284,11 @@ vi.mock("./doctor/shared/preview-warnings.js", () => { return []; } const botToken = params.account.botToken ?? params.parent?.botToken; - if (!botToken || asRecord(params.account.groups) || asRecord(params.parent?.groups)) { + if ( + !botToken || + readNullableRecord(params.account.groups) || + readNullableRecord(params.parent?.groups) + ) { return []; } if (hasStringEntries(params.account.groupAllowFrom ?? params.parent?.groupAllowFrom)) { @@ -1290,7 +1309,7 @@ vi.mock("./doctor/shared/preview-warnings.js", () => { doctorFixCommand: string; }): Promise { const warnings: string[] = []; - const telegram = asRecord(cfg.channels?.telegram); + const telegram = readNullableRecord(cfg.channels?.telegram); if (telegram) { const telegramBlocked = cfg.plugins?.enabled === false || cfg.plugins?.entries?.telegram?.enabled === false; @@ -1307,9 +1326,9 @@ vi.mock("./doctor/shared/preview-warnings.js", () => { prefix: "channels.telegram", }), ); - const accounts = asRecord(telegram.accounts); + const accounts = readNullableRecord(telegram.accounts); for (const [accountId, accountRaw] of Object.entries(accounts ?? {})) { - const account = asRecord(accountRaw); + const account = readNullableRecord(accountRaw); if (account) { warnings.push( ...telegramFirstTimeWarnings({ @@ -1322,7 +1341,7 @@ vi.mock("./doctor/shared/preview-warnings.js", () => { } } } - const imessage = asRecord(cfg.channels?.imessage); + const imessage = readNullableRecord(cfg.channels?.imessage); if (imessage?.groupPolicy === "allowlist" && !hasStringEntries(imessage.groupAllowFrom)) { warnings.push( '- channels.imessage.groupPolicy is "allowlist" but groupAllowFrom is empty — this channel does not fall back to allowFrom, so all group messages will be silently dropped.', diff --git a/src/commands/doctor/cron/legacy-store-migration.ts b/src/commands/doctor/cron/legacy-store-migration.ts index d6bb4733b042..765f88136660 100644 --- a/src/commands/doctor/cron/legacy-store-migration.ts +++ b/src/commands/doctor/cron/legacy-store-migration.ts @@ -2,11 +2,11 @@ import { createHash } from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { isRecord } from "../../../../packages/normalization-core/src/record-coerce.js"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString, normalizeOptionalStringifiedId, -} from "../../../../packages/normalization-core/src/string-coerce.js"; +} from "@openclaw/normalization-core/string-coerce"; import { coerceFiniteScheduleNumber } from "../../../cron/schedule-number.js"; import { normalizeCronStaggerMs } from "../../../cron/stagger.js"; import type { @@ -332,11 +332,11 @@ function parseCronStateFile(raw: string): { } } -function readString(record: Record, key: string): string | undefined { +function readScheduleString(record: Record, key: string): string | undefined { return normalizeOptionalString(record[key]); } -function readNumber(record: Record, key: string): number | undefined { +function readScheduleNumber(record: Record, key: string): number | undefined { return coerceFiniteScheduleNumber(record[key]); } @@ -347,13 +347,13 @@ function legacySchedulePayloadFromRecord( | { kind: "every"; everyMs: number; anchorMs?: number } | { kind: "cron"; expr: string; tz?: string; staggerMs?: number } | undefined { - const rawKind = readString(schedule, "kind")?.toLowerCase(); - const expr = readString(schedule, "expr") ?? readString(schedule, "cron"); - const at = readString(schedule, "at"); - const atMs = readNumber(schedule, "atMs"); - const everyMs = readNumber(schedule, "everyMs"); - const anchorMs = readNumber(schedule, "anchorMs"); - const tz = readString(schedule, "tz"); + const rawKind = readScheduleString(schedule, "kind")?.toLowerCase(); + const expr = readScheduleString(schedule, "expr") ?? readScheduleString(schedule, "cron"); + const at = readScheduleString(schedule, "at"); + const atMs = readScheduleNumber(schedule, "atMs"); + const everyMs = readScheduleNumber(schedule, "everyMs"); + const anchorMs = readScheduleNumber(schedule, "anchorMs"); + const tz = readScheduleString(schedule, "tz"); const staggerMs = normalizeCronStaggerMs(schedule.staggerMs); const kind = rawKind === "at" || rawKind === "every" || rawKind === "cron" diff --git a/src/commands/export-trajectory.ts b/src/commands/export-trajectory.ts index 53fdf5c12690..33f3ab074496 100644 --- a/src/commands/export-trajectory.ts +++ b/src/commands/export-trajectory.ts @@ -36,7 +36,7 @@ type EncodedExportTrajectoryRequest = { const ENCODED_EXPORT_REQUEST_RE = /^[A-Za-z0-9_-]{1,65536}$/u; -function readOptionalString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value : undefined; } @@ -59,23 +59,23 @@ function decodeExportTrajectoryRequest(encoded: string): Partial = {}; - const sessionKey = readOptionalString(request.sessionKey); + const sessionKey = readNonBlankString(request.sessionKey); if (sessionKey !== undefined) { opts.sessionKey = sessionKey; } - const output = readOptionalString(request.output); + const output = readNonBlankString(request.output); if (output !== undefined) { opts.output = output; } - const store = readOptionalString(request.store); + const store = readNonBlankString(request.store); if (store !== undefined) { opts.store = store; } - const agent = readOptionalString(request.agent); + const agent = readNonBlankString(request.agent); if (agent !== undefined) { opts.agent = agent; } - const workspace = readOptionalString(request.workspace); + const workspace = readNonBlankString(request.workspace); if (workspace !== undefined) { opts.workspace = workspace; } diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index 8319cc07c604..dcc3e08b95f1 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -2,6 +2,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { MAX_DATE_TIMESTAMP_MS } from "@openclaw/normalization-core/number-coercion"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { ProviderPlugin } from "../../plugins/types.js"; @@ -189,8 +190,6 @@ vi.mock("../../plugins/provider-auth-choice-helpers.js", async (importOriginal) const actual = await importOriginal(); const normalize = (value: string | undefined) => value?.trim().toLowerCase() ?? ""; - const isRecord = (value: unknown): value is Record => - Boolean(value && typeof value === "object" && !Array.isArray(value)); const mergePatch = (base: T, patch: unknown): T => { if (!isRecord(base) || !isRecord(patch)) { return patch as T; diff --git a/src/config/sessions/lifecycle.test.ts b/src/config/sessions/lifecycle.test.ts index 80dc6e1ab18e..dc5899736c28 100644 --- a/src/config/sessions/lifecycle.test.ts +++ b/src/config/sessions/lifecycle.test.ts @@ -132,9 +132,9 @@ describe("terminal main session transcript freshness", () => { expect(check(entry, sessionKey)).toBe(true); }); - it("reads the transcript header when the caller has no session key", async () => { + it("preserves Date.parse semantics for a numeric-looking transcript header", async () => { const sessionId = "session-header-without-key"; - const timestamp = "2026-01-01T12:00:00.000Z"; + const timestamp = "2026"; await appendTranscriptEvent( { agentId: "main", sessionId, sessionKey: "agent:main:header", storePath }, { type: "session", version: 3, id: sessionId, timestamp, cwd: stateDir }, diff --git a/src/config/sessions/lifecycle.ts b/src/config/sessions/lifecycle.ts index 4d85c821a262..03d33405028e 100644 --- a/src/config/sessions/lifecycle.ts +++ b/src/config/sessions/lifecycle.ts @@ -1,6 +1,6 @@ // Session lifecycle timestamps prefer store metadata and fall back to transcript headers. +import { asDateTimestampMs } from "@openclaw/normalization-core/number-coercion"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; -import { asDateTimestampMs } from "../../shared/number-coercion.js"; import { canonicalizeMainSessionAlias } from "./main-session.js"; import { loadTranscriptHeaderSync, readTranscriptStatsSync } from "./session-accessor.js"; import { isTerminalSessionStatus, type SessionEntry, type SessionScope } from "./types.js"; diff --git a/src/cron/schedule-identity.ts b/src/cron/schedule-identity.ts index 182a430a5e08..57ba363e279a 100644 --- a/src/cron/schedule-identity.ts +++ b/src/cron/schedule-identity.ts @@ -13,15 +13,11 @@ type CronScheduleIdentityInput = { schedule?: unknown; enabled?: unknown } & Rec unknown >; -function readString(record: Record, key: string): string | undefined { - return normalizeOptionalString(record[key]); -} - function readScheduleTime(record: Record, key: string): number | undefined { return coerceFiniteScheduleNumber(record[key]); } -function readNumber(record: Record, key: string): number | undefined { +function readScheduleInteger(record: Record, key: string): number | undefined { const parsed = parseStrictFiniteNumber(record[key]); return asSafeIntegerInRange(parsed, { min: Number.MIN_SAFE_INTEGER, @@ -44,12 +40,12 @@ function schedulePayloadFromRecord(schedule: Record): maxBatchBytes?: number; } | undefined { - const rawKind = readString(schedule, "kind")?.toLowerCase(); - const expr = readString(schedule, "expr"); - const at = readString(schedule, "at"); + const rawKind = normalizeOptionalString(schedule.kind)?.toLowerCase(); + const expr = normalizeOptionalString(schedule.expr); + const at = normalizeOptionalString(schedule.at); const everyMs = readScheduleTime(schedule, "everyMs"); const anchorMs = readScheduleTime(schedule, "anchorMs"); - const tz = readString(schedule, "tz"); + const tz = normalizeOptionalString(schedule.tz); const staggerMs = normalizeCronStaggerMs(schedule.staggerMs); const kind = // Infer legacy shorthand schedule shapes when kind is missing so timer @@ -78,8 +74,10 @@ function schedulePayloadFromRecord(schedule: Record): return { kind: "cron", expr, tz, staggerMs }; } if (kind === "on-exit") { - const command = readString(schedule, "command"); - return command ? { kind: "on-exit", command, cwd: readString(schedule, "cwd") } : undefined; + const command = normalizeOptionalString(schedule.command); + return command + ? { kind: "on-exit", command, cwd: normalizeOptionalString(schedule.cwd) } + : undefined; } if (kind === "stream") { const command = schedule.command; @@ -90,15 +88,15 @@ function schedulePayloadFromRecord(schedule: Record): ) { return undefined; } - const mode = readString(schedule, "mode"); + const mode = normalizeOptionalString(schedule.mode); return { kind: "stream", command: [...command], - cwd: readString(schedule, "cwd"), + cwd: normalizeOptionalString(schedule.cwd), mode: mode === "line" || mode === "match" ? mode : undefined, match: typeof schedule.match === "string" ? schedule.match : undefined, - batchMs: readNumber(schedule, "batchMs"), - maxBatchBytes: readNumber(schedule, "maxBatchBytes"), + batchMs: readScheduleInteger(schedule, "batchMs"), + maxBatchBytes: readScheduleInteger(schedule, "maxBatchBytes"), }; } return undefined; diff --git a/src/fleet/containers.runtime.ts b/src/fleet/containers.runtime.ts index c499f3699aeb..f7ed119cc6ed 100644 --- a/src/fleet/containers.runtime.ts +++ b/src/fleet/containers.runtime.ts @@ -135,7 +135,7 @@ function requireNonNegativeNumber(value: unknown): number { return value; } -function readOptionalString(value: unknown): string | undefined { +function readOptionalInspectString(value: unknown): string | undefined { if (value === undefined || value === null || value === "") { return undefined; } @@ -194,7 +194,7 @@ function readRestartPolicy(value: unknown): string | undefined { if (value === undefined || value === null) { return undefined; } - return readOptionalString(requireRecord(value).Name); + return readOptionalInspectString(requireRecord(value).Name); } function readPortBindings( @@ -231,7 +231,7 @@ function readNetworkAttachments(value: unknown): Array<{ id: string; name?: stri throw new InvalidInspectOutputError(); } const attachment = requireRecord(rawAttachment); - const name = readOptionalString(attachment.Name ?? attachment.name); + const name = readOptionalInspectString(attachment.Name ?? attachment.name); const normalized: { id: string; name?: string } = { id }; if (name) { normalized.name = name; @@ -284,8 +284,8 @@ function parseInspectOutput(stdout: string): Extract { - return typeof value === "object" && value !== null ? (value as Record) : {}; -} - function expectRecord(value: unknown, label: string): Record { - if (!value || typeof value !== "object") { + if (!isRecord(value)) { throw new Error(`expected ${label}`); } - expect(Array.isArray(value), label).toBe(false); - return value as Record; -} - -function readString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + return value; } function expectNonEmptyString(value: unknown, label: string): string { - const text = readString(value); + const text = normalizeNullableString(value); if (text === null) { throw new Error(`expected ${label}`); } @@ -97,7 +90,7 @@ function parseErrorCode(message: string): string { } function readGatewayErrorCode(err: unknown, fallbackMessage: string): string { - const byField = readString(asRecord(err).gatewayCode); + const byField = normalizeNullableString(asRecord(err).gatewayCode); if (byField) { return byField; } @@ -362,13 +355,13 @@ const COMMAND_PROFILES: Record = { async function resolveGatewayConnection() { const cfg = await readLiveTestConfig(); - const urlOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_URL); + const urlOverride = normalizeNullableString(process.env.OPENCLAW_ANDROID_GATEWAY_URL); const details = buildGatewayConnectionDetails({ config: cfg, ...(urlOverride ? { url: urlOverride } : {}), }); - const tokenOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_TOKEN); - const passwordOverride = readString(process.env.OPENCLAW_ANDROID_GATEWAY_PASSWORD); + const tokenOverride = normalizeNullableString(process.env.OPENCLAW_ANDROID_GATEWAY_TOKEN); + const passwordOverride = normalizeNullableString(process.env.OPENCLAW_ANDROID_GATEWAY_PASSWORD); const creds = resolveGatewayCredentialsFromConfig({ cfg, explicitAuth: { @@ -482,16 +475,16 @@ async function connectGatewayClient(params: { } function isAndroidNode(node: NodeListNode): boolean { - const platform = readString(node.platform)?.toLowerCase(); + const platform = normalizeNullableString(node.platform)?.toLowerCase(); if (platform === "android") { return true; } - const displayName = readString(node.displayName)?.toLowerCase(); + const displayName = normalizeNullableString(node.displayName)?.toLowerCase(); return displayName?.includes("android") === true; } function selectTargetNode(nodes: NodeListNode[]): NodeListNode { - const nodeIdOverride = readString(process.env.OPENCLAW_ANDROID_NODE_ID); + const nodeIdOverride = normalizeNullableString(process.env.OPENCLAW_ANDROID_NODE_ID); if (nodeIdOverride) { const match = nodes.find((node) => node.nodeId === nodeIdOverride); if (!match) { @@ -500,10 +493,12 @@ function selectTargetNode(nodes: NodeListNode[]): NodeListNode { return match; } - const nodeNameOverride = readString(process.env.OPENCLAW_ANDROID_NODE_NAME)?.toLowerCase(); + const nodeNameOverride = normalizeNullableString( + process.env.OPENCLAW_ANDROID_NODE_NAME, + )?.toLowerCase(); if (nodeNameOverride) { const match = nodes.find( - (node) => readString(node.displayName)?.toLowerCase() === nodeNameOverride, + (node) => normalizeNullableString(node.displayName)?.toLowerCase() === nodeNameOverride, ); if (!match) { throw new Error(`OPENCLAW_ANDROID_NODE_NAME not found in node.list: ${nodeNameOverride}`); diff --git a/src/gateway/android-node.capabilities.policy-config.test.ts b/src/gateway/android-node.capabilities.policy-config.test.ts index 108a1e1fa328..3a90fb0f5aa5 100644 --- a/src/gateway/android-node.capabilities.policy-config.test.ts +++ b/src/gateway/android-node.capabilities.policy-config.test.ts @@ -1,13 +1,10 @@ +import { asRecord } from "@openclaw/normalization-core/record-coerce"; /** * Android node capability policy-config regression tests. */ import { describe, expect, it } from "vitest"; import { unwrapRemoteConfigSnapshot } from "../../test/helpers/gateway/android-node-capabilities-policy-config.js"; -function asRecord(value: unknown): Record { - return typeof value === "object" && value !== null ? (value as Record) : {}; -} - describe("unwrapRemoteConfigSnapshot", () => { it("reads direct config snapshot payload from GatewayClient.request", () => { const cfg = unwrapRemoteConfigSnapshot({ gateway: { bind: "127.0.0.1" } }); diff --git a/src/gateway/cli-session-history.claude.ts b/src/gateway/cli-session-history.claude.ts index fbce81bcb4c4..18cf37068035 100644 --- a/src/gateway/cli-session-history.claude.ts +++ b/src/gateway/cli-session-history.claude.ts @@ -69,11 +69,7 @@ export function resolveClaudeCliBindingSessionId( } export function resolveClaudeCliTimestampMs(value: unknown): number | undefined { - if (typeof value !== "string") { - return undefined; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; + return typeof value === "string" ? asFiniteNumber(Date.parse(value)) : undefined; } function resolveClaudeCliUsage(raw: ClaudeCliUsage) { diff --git a/src/gateway/cli-session-history.test.ts b/src/gateway/cli-session-history.test.ts index 1c7d0dd7826f..195a3ecf6d9a 100644 --- a/src/gateway/cli-session-history.test.ts +++ b/src/gateway/cli-session-history.test.ts @@ -246,6 +246,34 @@ describe("cli session history", () => { }); }); + it("preserves Date.parse semantics for numeric-looking Claude timestamps", async () => { + await withClaudeProjectsDir(async ({ homeDir, sessionId, filePath }) => { + await fs.writeFile( + filePath, + [ + { timestamp: "0", uuid: "numeric-zero", content: "zero" }, + { timestamp: "2026", uuid: "numeric-year", content: "year" }, + ] + .map((entry) => + JSON.stringify({ + type: "user", + uuid: entry.uuid, + timestamp: entry.timestamp, + message: { role: "user", content: entry.content }, + }), + ) + .join("\n"), + "utf-8", + ); + + const messages = readClaudeCliSessionMessages({ cliSessionId: sessionId, homeDir }); + expect(messages.map((message) => message.timestamp)).toEqual([ + Date.parse("0"), + Date.parse("2026"), + ]); + }); + }); + it("assigns stable source-line ids when Claude entries have no uuid", async () => { await withClaudeProjectsDir(async ({ homeDir, sessionId, filePath }) => { await fs.writeFile( diff --git a/src/gateway/control-ui-github-api.ts b/src/gateway/control-ui-github-api.ts index 5765d599deab..bdd7df4be979 100644 --- a/src/gateway/control-ui-github-api.ts +++ b/src/gateway/control-ui-github-api.ts @@ -28,7 +28,10 @@ export function requiredString(record: Record, key: string): st return value; } -export function optionalString(record: Record, key: string): string | undefined { +export function readOptionalGitHubString( + record: Record, + key: string, +): string | undefined { const value = record[key]; return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/src/gateway/control-ui-github-preview.ts b/src/gateway/control-ui-github-preview.ts index 349d93050c4d..93ad50f21932 100644 --- a/src/gateway/control-ui-github-preview.ts +++ b/src/gateway/control-ui-github-preview.ts @@ -10,7 +10,7 @@ import { githubApiToken, isRecord, optionalNumber, - optionalString, + readOptionalGitHubString, readBoundedResponse, readGitHubJsonResponse, requiredString, @@ -159,19 +159,19 @@ function parseGitHubResponse( ...target, additions: optionalNumber(value, "additions"), changedFiles: optionalNumber(value, "changed_files"), - closedAt: optionalString(value, "closed_at"), + closedAt: readOptionalGitHubString(value, "closed_at"), comments: optionalNumber(value, "comments"), createdAt: requiredString(value, "created_at"), deletions: optionalNumber(value, "deletions"), draft: typeof value.draft === "boolean" ? value.draft : undefined, - login: optionalString(user, "login") ?? "ghost", - mergedAt: optionalString(value, "merged_at"), + login: readOptionalGitHubString(user, "login") ?? "ghost", + mergedAt: readOptionalGitHubString(value, "merged_at"), state: requiredString(value, "state"), - stateReason: optionalString(value, "state_reason"), + stateReason: readOptionalGitHubString(value, "state_reason"), title: requiredString(value, "title"), updatedAt: requiredString(value, "updated_at"), }, - avatarUrl: optionalString(user, "avatar_url"), + avatarUrl: readOptionalGitHubString(user, "avatar_url"), }; } diff --git a/src/gateway/control-ui-session-prs.ts b/src/gateway/control-ui-session-prs.ts index e0887e0a755c..b081fdfaad36 100644 --- a/src/gateway/control-ui-session-prs.ts +++ b/src/gateway/control-ui-session-prs.ts @@ -19,7 +19,7 @@ import { githubApiToken, isRecord, optionalNumber, - optionalString, + readOptionalGitHubString, } from "./control-ui-github-api.js"; import { gitOutput, @@ -314,7 +314,7 @@ async function resolveSessionBranch( } function derivePullState(value: Record): ControlUiSessionPullRequest["state"] { - if (optionalString(value, "merged_at")) { + if (readOptionalGitHubString(value, "merged_at")) { return "merged"; } if (value.state !== "open") { @@ -328,13 +328,13 @@ function parsePullListItem(value: unknown): PullListItem | null { return null; } const number = optionalNumber(value, "number"); - const title = optionalString(value, "title"); - const url = optionalString(value, "html_url"); + const title = readOptionalGitHubString(value, "title"); + const url = readOptionalGitHubString(value, "html_url"); const base = isRecord(value.base) ? value.base : {}; const baseRepo = isRecord(base.repo) ? base.repo : {}; const baseOwner = isRecord(baseRepo.owner) ? baseRepo.owner : {}; - const owner = optionalString(baseOwner, "login"); - const repo = optionalString(baseRepo, "name"); + const owner = readOptionalGitHubString(baseOwner, "login"); + const repo = readOptionalGitHubString(baseRepo, "name"); const head = isRecord(value.head) ? value.head : {}; if (!number || !Number.isSafeInteger(number) || number < 1 || !title || !url || !owner || !repo) { return null; @@ -346,9 +346,9 @@ function parsePullListItem(value: unknown): PullListItem | null { owner, repo, state: derivePullState(value), - headSha: optionalString(head, "sha"), - baseRef: optionalString(base, "ref"), - mergeCommitSha: optionalString(value, "merge_commit_sha"), + headSha: readOptionalGitHubString(head, "sha"), + baseRef: readOptionalGitHubString(base, "ref"), + mergeCommitSha: readOptionalGitHubString(value, "merge_commit_sha"), }; } @@ -378,8 +378,8 @@ async function fetchParentRepo( return null; } const parentOwner = isRecord(value.parent.owner) ? value.parent.owner : {}; - const parentLogin = optionalString(parentOwner, "login"); - const parentName = optionalString(value.parent, "name"); + const parentLogin = readOptionalGitHubString(parentOwner, "login"); + const parentName = readOptionalGitHubString(value.parent, "name"); return parentLogin && parentName ? { owner: parentLogin, repo: parentName } : null; } @@ -431,7 +431,7 @@ function rollupCheckRuns(value: unknown): ControlUiSessionPullRequest["checks"] let running = 0; for (const runValue of value.check_runs) { const run = isRecord(runValue) ? runValue : {}; - const conclusion = optionalString(run, "conclusion"); + const conclusion = readOptionalGitHubString(run, "conclusion"); if (conclusion && FAILING_CHECK_CONCLUSIONS.has(conclusion)) { failed += 1; continue; diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 15092812c017..3bfc8813baf8 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -12,6 +12,7 @@ import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { expectDefined } from "@openclaw/normalization-core"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { clampThinkingLevel, type Api, @@ -1271,10 +1272,6 @@ function resolveBedrockDiscoveryRegion(cfg: OpenClawConfig | undefined): string return typeof region === "string" ? normalizeOptionalEnvValue(region) : undefined; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function resolveAwsProfileRegion(env: NodeJS.ProcessEnv): string | undefined { const profile = normalizeOptionalEnvValue(env.AWS_PROFILE) ?? "default"; const homeDir = normalizeOptionalEnvValue(env.HOME) ?? os.homedir(); diff --git a/src/gateway/mcp-app-reconstruction.ts b/src/gateway/mcp-app-reconstruction.ts index 86d606ec9ecd..911e16103d30 100644 --- a/src/gateway/mcp-app-reconstruction.ts +++ b/src/gateway/mcp-app-reconstruction.ts @@ -1,5 +1,6 @@ import { type CallToolResult, ContentBlockSchema } from "@modelcontextprotocol/sdk/types.js"; -import { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { BoardMcpAppDescriptor } from "../../packages/gateway-protocol/src/index.js"; import { getOrCreateSessionMcpRuntime } from "../agents/agent-bundle-mcp-runtime.js"; import type { SessionMcpRuntime } from "../agents/agent-bundle-mcp-types.js"; @@ -45,18 +46,13 @@ type TranscriptResultRead = | { kind: "restorable"; value: TranscriptResult } | { kind: "unavailable" }; -function readString(record: Record | undefined, key: string): string | undefined { - const value = record?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readDescriptor(value: unknown): McpAppDescriptor | undefined { - const record = asRecord(value); - const viewId = readString(record, "viewId"); - const serverName = readString(record, "serverName"); - const toolName = readString(record, "toolName"); - const uiResourceUri = readString(record, "uiResourceUri"); - const toolCallId = readString(record, "toolCallId"); + const record = asOptionalRecord(value); + const viewId = normalizeOptionalString(record?.viewId); + const serverName = normalizeOptionalString(record?.serverName); + const toolName = normalizeOptionalString(record?.toolName); + const uiResourceUri = normalizeOptionalString(record?.uiResourceUri); + const toolCallId = normalizeOptionalString(record?.toolCallId); const rawResultMetaState = record?.resultMetaState; const resultMetaState = rawResultMetaState === "unavailable" ? rawResultMetaState : undefined; if ( @@ -89,22 +85,27 @@ function readToolInputFromMessage( toolCallId: string, modelToolName: string, ): { found: true; input: unknown } | undefined { - const message = asRecord(value); - if (readString(message, "role")?.toLowerCase() !== "assistant") { + const message = asOptionalRecord(value); + if (normalizeOptionalString(message?.role)?.toLowerCase() !== "assistant") { return undefined; } const content = Array.isArray(message?.content) ? message.content : []; for (const blockValue of content) { - const block = asRecord(blockValue); - if ((readString(block, "id") ?? readString(block, "toolCallId")) !== toolCallId) { + const block = asOptionalRecord(blockValue); + if ( + (normalizeOptionalString(block?.id) ?? normalizeOptionalString(block?.toolCallId)) !== + toolCallId + ) { continue; } - const type = readString(block, "type")?.toLowerCase(); + const type = normalizeOptionalString(block?.type)?.toLowerCase(); if (type !== "toolcall" && type !== "tool_call" && type !== "tooluse" && type !== "tool_use") { continue; } const blockToolName = - readString(block, "name") ?? readString(block, "toolName") ?? readString(block, "tool_name"); + normalizeOptionalString(block?.name) ?? + normalizeOptionalString(block?.toolName) ?? + normalizeOptionalString(block?.tool_name); if (blockToolName !== modelToolName) { continue; } @@ -134,14 +135,14 @@ function matchesLookup( lookup: TranscriptLookup, ): boolean { if ("viewId" in lookup) { - return readString(rawDescriptor, "viewId") === lookup.viewId; + return normalizeOptionalString(rawDescriptor?.viewId) === lookup.viewId; } const descriptor = lookup.descriptor; return ( - readString(rawDescriptor, "serverName") === descriptor.serverName && - readString(rawDescriptor, "toolName") === descriptor.toolName && - readString(rawDescriptor, "uiResourceUri") === descriptor.uiResourceUri && - readString(rawDescriptor, "toolCallId") === descriptor.toolCallId + normalizeOptionalString(rawDescriptor?.serverName) === descriptor.serverName && + normalizeOptionalString(rawDescriptor?.toolName) === descriptor.toolName && + normalizeOptionalString(rawDescriptor?.uiResourceUri) === descriptor.uiResourceUri && + normalizeOptionalString(rawDescriptor?.toolCallId) === descriptor.toolCallId ); } @@ -149,28 +150,29 @@ function readTranscriptResult( value: unknown, lookup: TranscriptLookup, ): TranscriptResultRead | undefined { - const message = asRecord(value); - if (!message || readString(message, "role")?.toLowerCase() !== "toolresult") { + const message = asOptionalRecord(value); + if (!message || normalizeOptionalString(message.role)?.toLowerCase() !== "toolresult") { return undefined; } - const details = asRecord(message.details); + const details = asOptionalRecord(message.details); if (!details) { return undefined; } - const preview = asRecord(details.mcpAppPreview); - const rawDescriptor = asRecord(preview?.mcpApp); + const preview = asOptionalRecord(details.mcpAppPreview); + const rawDescriptor = asOptionalRecord(preview?.mcpApp); if (!matchesLookup(rawDescriptor, lookup)) { return undefined; } const descriptor = readDescriptor(rawDescriptor); - const modelToolName = readString(message, "toolName") ?? readString(message, "tool_name"); + const modelToolName = + normalizeOptionalString(message.toolName) ?? normalizeOptionalString(message.tool_name); if (!descriptor || !modelToolName) { return { kind: "unavailable" }; } if ( - readString(message, "toolCallId") !== descriptor.toolCallId || - readString(details, "mcpServer") !== descriptor.serverName || - readString(details, "mcpTool") !== descriptor.toolName || + normalizeOptionalString(message.toolCallId) !== descriptor.toolCallId || + normalizeOptionalString(details.mcpServer) !== descriptor.serverName || + normalizeOptionalString(details.mcpTool) !== descriptor.toolName || descriptor.resultMetaState === "unavailable" ) { return { kind: "unavailable" }; diff --git a/src/gateway/mcp-app-standalone.ts b/src/gateway/mcp-app-standalone.ts index 09b67c00109b..3734c0d71b49 100644 --- a/src/gateway/mcp-app-standalone.ts +++ b/src/gateway/mcp-app-standalone.ts @@ -276,12 +276,12 @@ function runStandaloneMcpAppHost(config: { let sandboxOrigin: string | undefined; let teardownId: JsonRpcId | undefined; - const asRecord = (value: unknown): Record | undefined => + const asStandaloneRecord = (value: unknown): Record | undefined => value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; const errorMessage = (error: unknown, timeoutMessage: string) => - asRecord(error)?.name === "TimeoutError" + asStandaloneRecord(error)?.name === "TimeoutError" ? timeoutMessage : error instanceof Error ? error.message @@ -380,23 +380,23 @@ function runStandaloneMcpAppHost(config: { } initialized = true; notify("ui/notifications/tool-input", { - arguments: asRecord(payload.toolInput) ?? {}, + arguments: asStandaloneRecord(payload.toolInput) ?? {}, }); notify("ui/notifications/tool-result", payload.toolResult); }; const isValidInitialize = (params: unknown) => { - const record = asRecord(params); - const appInfo = asRecord(record?.appInfo); + const record = asStandaloneRecord(params); + const appInfo = asStandaloneRecord(record?.appInfo); return ( typeof record?.protocolVersion === "string" && typeof appInfo?.name === "string" && typeof appInfo?.version === "string" && - asRecord(record?.appCapabilities) !== undefined + asStandaloneRecord(record?.appCapabilities) !== undefined ); }; browser.addEventListener("message", (event) => { - const message = asRecord(event.data) as JsonRpcMessage | undefined; + const message = asStandaloneRecord(event.data) as JsonRpcMessage | undefined; if ( event.source !== frame?.contentWindow || event.origin !== sandboxOrigin || @@ -453,7 +453,7 @@ function runStandaloneMcpAppHost(config: { return; } if (message.method === "ui/notifications/size-changed") { - const height = asRecord(message.params)?.height; + const height = asStandaloneRecord(message.params)?.height; if (frame && typeof height === "number" && Number.isFinite(height)) { frame.style.height = `${Math.min(1200, Math.max(160, Math.round(height)))}px`; } diff --git a/src/gateway/node-normalize.ts b/src/gateway/node-normalize.ts deleted file mode 100644 index f3a8ebb41435..000000000000 --- a/src/gateway/node-normalize.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Normalize optional string-ish websocket fields. Leaf module (no gateway imports). */ -export function normalizeString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} diff --git a/src/gateway/node-plugin-tool-snapshot.ts b/src/gateway/node-plugin-tool-snapshot.ts index a515a30d8089..f343b303462c 100644 --- a/src/gateway/node-plugin-tool-snapshot.ts +++ b/src/gateway/node-plugin-tool-snapshot.ts @@ -1,5 +1,6 @@ /** Connected node-hosted plugin tools available to agent tool resolution. */ import { asOptionalRecord as normalizeRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-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"; @@ -41,10 +42,6 @@ function bumpSnapshotVersion(): void { snapshotVersion += 1; } -function normalizeString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - function defaultParameters(): Record { return { type: "object", properties: {}, additionalProperties: true }; } @@ -59,14 +56,14 @@ export function createRegisteredNodePluginToolDescriptorMap( const descriptors = new Map(); for (const entry of commands ?? []) { const agentTool = entry.command.agentTool; - const name = normalizeString(agentTool?.name); - const description = normalizeString(agentTool?.description); - const command = normalizeString(entry.command.command); + const name = normalizeOptionalString(agentTool?.name) ?? ""; + const description = normalizeOptionalString(agentTool?.description) ?? ""; + const command = normalizeOptionalString(entry.command.command) ?? ""; if (!isProviderSafeToolName(name) || !description || !command) { continue; } - const mcpServer = normalizeString(agentTool?.mcp?.server); - const mcpTool = normalizeString(agentTool?.mcp?.tool); + const mcpServer = normalizeOptionalString(agentTool?.mcp?.server) ?? ""; + const mcpTool = normalizeOptionalString(agentTool?.mcp?.tool) ?? ""; descriptors.set(`${entry.pluginId}\0${name}\0${command}`, { pluginId: entry.pluginId, name, @@ -104,13 +101,13 @@ export function normalizeNodePluginToolDescriptors(params: { // Paired nodes are the trust boundary for descriptors. Operators can disable // publication globally with gateway.nodes.pluginTools.enabled. for (const tool of params.tools ?? []) { - const pluginId = normalizeString(tool.pluginId); - const name = normalizeString(tool.name); - const description = normalizeString(tool.description).slice( + const pluginId = normalizeOptionalString(tool.pluginId) ?? ""; + const name = normalizeOptionalString(tool.name) ?? ""; + const description = (normalizeOptionalString(tool.description) ?? "").slice( 0, NODE_PLUGIN_TOOL_DESCRIPTION_MAX_LENGTH, ); - const command = normalizeString(tool.command); + const command = normalizeOptionalString(tool.command) ?? ""; if ( !pluginId || !isProviderSafeToolName(name) || @@ -134,12 +131,12 @@ export function normalizeNodePluginToolDescriptors(params: { `${pluginId}\0${name}\0${command}`, ); const descriptor = registeredDescriptor ?? tool; - const descriptorDescription = normalizeString(descriptor.description).slice( + const descriptorDescription = (normalizeOptionalString(descriptor.description) ?? "").slice( 0, NODE_PLUGIN_TOOL_DESCRIPTION_MAX_LENGTH, ); - const mcpServer = normalizeString(descriptor.mcp?.server); - const mcpTool = normalizeString(descriptor.mcp?.tool); + const mcpServer = normalizeOptionalString(descriptor.mcp?.server) ?? ""; + const mcpTool = normalizeOptionalString(descriptor.mcp?.tool) ?? ""; normalized.push({ descriptor: { pluginId, diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 8d2ddb8ee4bc..515e0a2ca9fb 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -7,6 +7,7 @@ import { resolveExpiresAtMsFromDurationMs, resolveTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { GATEWAY_CLIENT_IDS } from "../../packages/gateway-protocol/src/client-info.js"; // NodeSession is plugin-SDK-reachable; importing these types from the // gateway-protocol index would retain the whole ProtocolSchemas registry in @@ -19,7 +20,6 @@ import { setActiveNodeContext } from "../infra/active-node-context.js"; import type { PairedDeviceNodeBinding } from "../infra/device-pairing-node-state.js"; import { NODE_MCP_TOOLS_CALL_COMMAND } from "../infra/node-commands.js"; import { logRejectedLargePayload } from "../logging/diagnostic-payload.js"; -import { normalizeString } from "./node-normalize.js"; import { createRegisteredNodePluginToolDescriptorMap, normalizeNodePluginToolDescriptors, @@ -106,12 +106,12 @@ function resolvePendingSystemRunEvent(params: { return undefined; } const obj = params.params as Record; - const runId = normalizeString(obj.runId); + const runId = normalizeOptionalString(obj.runId) ?? ""; if (!runId) { return undefined; } const timeoutMs = normalizeSystemRunTimeoutMs(obj.timeoutMs); - const sessionKey = normalizeString(obj.sessionKey); + const sessionKey = normalizeOptionalString(obj.sessionKey) ?? ""; return { runId, ...(sessionKey ? { sessionKey } : {}), @@ -132,7 +132,7 @@ function normalizeSystemRunInvokeParams(params: { command: string; params?: unkn const obj = params.params as Record; const normalized: Record = { ...obj, - runId: normalizeString(obj.runId) || randomUUID(), + runId: normalizeOptionalString(obj.runId) || randomUUID(), }; const timeoutMs = normalizeSystemRunTimeoutMs(obj.timeoutMs); if (timeoutMs === undefined) { @@ -1168,7 +1168,7 @@ export class NodeRegistry { "params" in params && invokeParams !== undefined ? JSON.stringify(invokeParams) : null, timeoutMs, idempotencyKey: params.idempotencyKey, - sessionKey: normalizeString(params.sessionKey) || undefined, + sessionKey: normalizeOptionalString(params.sessionKey), }; const systemRunEvent = resolvePendingSystemRunEvent({ command: params.command, diff --git a/src/gateway/operator-approval-store.ts b/src/gateway/operator-approval-store.ts index fae2d49503f8..6e497073c663 100644 --- a/src/gateway/operator-approval-store.ts +++ b/src/gateway/operator-approval-store.ts @@ -1,3 +1,4 @@ +import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce"; // Persistent operator approval lifecycle and first-answer-wins transitions. import type { Selectable } from "kysely"; import { @@ -211,12 +212,12 @@ CREATE TABLE IF NOT EXISTS operator_approval_execution_identities ( function normalizeExecutionIdentityBinding(input: NewOperatorApproval) { const binding = input.executionIdentityToken; - const sourceRunId = normalizeString(input.source?.runId); - if (!binding || normalizeString(binding.runId) !== sourceRunId) { + const sourceRunId = normalizeNullableString(input.source?.runId); + if (!binding || normalizeNullableString(binding.runId) !== sourceRunId) { return undefined; } - const sourceContextId = normalizeString(binding.contextId); - const sourceExecutionId = normalizeString(binding.executionId); + const sourceContextId = normalizeNullableString(binding.contextId); + const sourceExecutionId = normalizeNullableString(binding.executionId); if ( !sourceContextId || !sourceExecutionId || @@ -252,13 +253,8 @@ function parseStringArray(raw: string): string[] | null { } } -function normalizeString(value: string | null | undefined): string | null { - const normalized = value?.trim(); - return normalized ? normalized : null; -} - function requireString(value: string, label: string): string { - const normalized = normalizeString(value); + const normalized = normalizeNullableString(value); if (!normalized) { throw new Error(`${label} must not be empty`); } @@ -307,7 +303,7 @@ function decodeOperatorApprovalHistoryCursor(raw: string): OperatorApprovalHisto function normalizeStringArray(values: readonly string[] | undefined): string[] { const result: string[] = []; for (const value of values ?? []) { - const normalized = normalizeString(value); + const normalized = normalizeNullableString(value); if (normalized && !result.includes(normalized)) { result.push(normalized); } @@ -617,16 +613,16 @@ function inputMatchesExistingRow( row.status === "pending" && row.kind === input.kind && row.presentation_json === serialized.presentationJson && - row.requested_by_device_id === normalizeString(input.requester?.deviceId) && - row.requested_by_client_id === normalizeString(input.requester?.clientId) && + row.requested_by_device_id === normalizeNullableString(input.requester?.deviceId) && + row.requested_by_client_id === normalizeNullableString(input.requester?.clientId) && row.requested_by_device_token_auth === (input.requester?.deviceTokenAuth === true ? 1 : 0) && row.reviewer_device_ids_json === serialized.reviewerDeviceIdsJson && - row.source_agent_id === normalizeString(source.agentId) && - row.source_session_key === normalizeString(source.sessionKey) && - row.source_session_id === normalizeString(source.sessionId) && - row.source_run_id === normalizeString(source.runId) && - row.source_tool_call_id === normalizeString(source.toolCallId) && - row.source_tool_name === normalizeString(source.toolName) && + row.source_agent_id === normalizeNullableString(source.agentId) && + row.source_session_key === normalizeNullableString(source.sessionKey) && + row.source_session_id === normalizeNullableString(source.sessionId) && + row.source_run_id === normalizeNullableString(source.runId) && + row.source_tool_call_id === normalizeNullableString(source.toolCallId) && + row.source_tool_name === normalizeNullableString(source.toolName) && row.audience_session_keys_json === serialized.audienceSessionKeysJson && row.runtime_epoch === input.runtimeEpoch.trim() && row.created_at_ms === input.createdAtMs && @@ -694,16 +690,16 @@ export function insertOperatorApproval(params: { kind: input.kind, status: "pending", presentation_json: presentationJson, - requested_by_device_id: normalizeString(input.requester?.deviceId), - requested_by_client_id: normalizeString(input.requester?.clientId), + requested_by_device_id: normalizeNullableString(input.requester?.deviceId), + requested_by_client_id: normalizeNullableString(input.requester?.clientId), requested_by_device_token_auth: input.requester?.deviceTokenAuth === true ? 1 : 0, reviewer_device_ids_json: reviewerDeviceIdsJson, - source_agent_id: normalizeString(source.agentId), - source_session_key: normalizeString(source.sessionKey), - source_session_id: normalizeString(source.sessionId), - source_run_id: normalizeString(source.runId), - source_tool_call_id: normalizeString(source.toolCallId), - source_tool_name: normalizeString(source.toolName), + source_agent_id: normalizeNullableString(source.agentId), + source_session_key: normalizeNullableString(source.sessionKey), + source_session_id: normalizeNullableString(source.sessionId), + source_run_id: normalizeNullableString(source.runId), + source_tool_call_id: normalizeNullableString(source.toolCallId), + source_tool_name: normalizeNullableString(source.toolName), audience_session_keys_json: audienceSessionKeysJson, runtime_epoch: runtimeEpoch, created_at_ms: input.createdAtMs, @@ -982,7 +978,7 @@ export function resolveOperatorApproval(params: { databaseOptions?: OpenClawStateDatabaseOptions; }): ResolveOperatorApprovalResult { const id = requireApprovalId(params.id); - const resolverId = normalizeString(params.resolver.id); + const resolverId = normalizeNullableString(params.resolver.id); const runtimeEpoch = params.runtimeEpoch === undefined ? undefined @@ -1130,7 +1126,7 @@ export function forceDenyOperatorApproval(params: { terminal_reason: params.reason, resolved_at_ms: auditTimestampMs, resolver_kind: params.resolver.kind, - resolver_id: normalizeString(params.resolver.id), + resolver_id: normalizeNullableString(params.resolver.id), updated_at_ms: auditTimestampMs, }) .where("approval_id", "=", id) diff --git a/src/gateway/server-methods/attach.ts b/src/gateway/server-methods/attach.ts index 1276e1f0fb27..3361d2198a52 100644 --- a/src/gateway/server-methods/attach.ts +++ b/src/gateway/server-methods/attach.ts @@ -1,3 +1,5 @@ +import { asRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; import { resolveMainSessionKey } from "../../config/sessions.js"; import { resolveSessionEntryAccessTarget } from "../../config/sessions/session-accessor.js"; @@ -14,15 +16,6 @@ import { } from "../mcp-http.loopback-runtime.js"; import type { GatewayRequestHandlers } from "./types.js"; -function paramRecord(params: unknown): Record { - return params && typeof params === "object" ? (params as Record) : {}; -} - -function readString(params: Record, key: string): string | undefined { - const value = params[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function readPositiveNumber(params: Record, key: string): number | undefined { const value = params[key]; return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; @@ -30,10 +23,12 @@ function readPositiveNumber(params: Record, key: string): numbe export const attachHandlers: GatewayRequestHandlers = { "attach.grant": async ({ params, respond, context }) => { - const grantParams = paramRecord(params); + const grantParams = asRecord(params); const cfg = context.getRuntimeConfig(); - const sessionKey = readString(grantParams, "sessionKey") ?? resolveMainSessionKey(cfg); - const agentId = sessionKey === "global" ? readString(grantParams, "agentId") : undefined; + const sessionKey = + normalizeOptionalString(grantParams.sessionKey) ?? resolveMainSessionKey(cfg); + const agentId = + sessionKey === "global" ? normalizeOptionalString(grantParams.agentId) : undefined; const harnessEntry = isAgentHarnessSessionKey(sessionKey) ? resolveSessionEntryAccessTarget({ cfg, sessionKey }).entry : undefined; @@ -74,7 +69,7 @@ export const attachHandlers: GatewayRequestHandlers = { }); }, "attach.revoke": async ({ params, respond }) => { - const token = readString(paramRecord(params), "token"); + const token = normalizeOptionalString(asRecord(params).token); if (!token) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "token is required")); return; diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 7ed882708a38..562cf35cfedd 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -1,5 +1,6 @@ // Gateway plugin tests cover plugin loading, auto-enable, runtime registry setup, // request-scope injection, diagnostics, and handler dispatch integration. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { @@ -240,10 +241,6 @@ function createTestContext(label: string): GatewayRequestContext { return { label } as unknown as GatewayRequestContext; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function getLastMockFirstArg( diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index b581bfd0c02c..1bc8c380788b 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -41,13 +41,13 @@ type MockCalls = { type SessionStoreEntryOptions = Parameters[1]; type MutationMethod = "sessions.patch" | "sessions.compact"; -function isRecord(value: unknown): value is Record { +function isObjectRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function requireRecord(value: unknown, label: string): Record { - expect(isRecord(value), `${label} should be an object`).toBe(true); - if (!isRecord(value)) { + expect(isObjectRecord(value), `${label} should be an object`).toBe(true); + if (!isObjectRecord(value)) { throw new Error(`${label} should be an object`); } return value; @@ -70,7 +70,7 @@ function expectFields(record: Record, expected: Record - isRecord(event) && isRecord(event.message) ? event.message.content : undefined, + isObjectRecord(event) && isObjectRecord(event.message) ? event.message.content : undefined, ) .filter((content) => content !== undefined); } @@ -90,7 +90,7 @@ function findSession( const sessions = requireArray(payload.sessions, "response sessions"); const session = sessions.find( (candidate): candidate is Record => - isRecord(candidate) && candidate.key === sessionKey, + isObjectRecord(candidate) && candidate.key === sessionKey, ); if (!session) { throw new Error(`Missing session ${sessionKey}`); diff --git a/src/gateway/server.shared-token-session-rotation.test.ts b/src/gateway/server.shared-token-session-rotation.test.ts index 03170893259c..f748074a0d3d 100644 --- a/src/gateway/server.shared-token-session-rotation.test.ts +++ b/src/gateway/server.shared-token-session-rotation.test.ts @@ -2,6 +2,7 @@ * Shared gateway-token session rotation tests. */ import fs from "node:fs/promises"; +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { loadGatewayConfig, @@ -80,17 +81,11 @@ afterAll(async () => { await server.close(); }); -function toRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - function buildConfigSetWithRotatedToken(config: Record): Record { const next = structuredClone(config); - const gateway = { ...toRecord(next.gateway) }; - const auth = { ...toRecord(gateway.auth), mode: "token", token: NEW_TOKEN }; - const reload = { ...toRecord(gateway.reload), mode: "off" }; + const gateway = { ...asOptionalRecord(next.gateway) }; + const auth = { ...asOptionalRecord(gateway.auth), mode: "token", token: NEW_TOKEN }; + const reload = { ...asOptionalRecord(gateway.reload), mode: "off" }; gateway.auth = auth; gateway.reload = reload; next.gateway = gateway; diff --git a/src/gateway/session-transcript-readers.test.ts b/src/gateway/session-transcript-readers.test.ts index 507387688645..e5977f34fbb6 100644 --- a/src/gateway/session-transcript-readers.test.ts +++ b/src/gateway/session-transcript-readers.test.ts @@ -202,6 +202,33 @@ describe("session transcript reader facade", () => { }); }); + test("preserves Date.parse semantics for numeric-looking record timestamps", async () => { + const scope = await writeTranscript("reader-numeric-looking-timestamps", [ + { type: "session", version: 3, id: "reader-numeric-looking-timestamps" }, + { + type: "message", + id: "numeric-zero", + parentId: null, + timestamp: "0", + message: { role: "user", content: "zero" }, + }, + { + type: "message", + id: "numeric-year", + parentId: "numeric-zero", + timestamp: "2026", + message: { role: "assistant", content: "year" }, + }, + ]); + + await expect( + readSessionMessagesAsync(scope, { mode: "full", reason: "timestamp contract test" }), + ).resolves.toMatchObject([ + { __openclaw: { recordTimestampMs: Date.parse("0") } }, + { __openclaw: { recordTimestampMs: Date.parse("2026") } }, + ]); + }); + test("finds an anchored reset-archive message by historical session id", async () => { const sessionId = "reader-file-archive-anchor"; const scope = await writeTranscript(sessionId, [ diff --git a/src/logging/diagnostic-stability-bundle.ts b/src/logging/diagnostic-stability-bundle.ts index cf061612a3de..c43804ff3375 100644 --- a/src/logging/diagnostic-stability-bundle.ts +++ b/src/logging/diagnostic-stability-bundle.ts @@ -256,7 +256,7 @@ function readObject(value: unknown, label: string): Record { return value as Record; } -function readNumber(value: unknown, label: string): number { +function readRequiredNumber(value: unknown, label: string): number { if (typeof value !== "number" || !Number.isFinite(value)) { throw new Error(`Invalid stability bundle: ${label} must be a finite number`); } @@ -267,12 +267,12 @@ function readOptionalPositiveInteger(value: unknown, label: string): number | un if (value === undefined) { return undefined; } - const parsed = readNumber(value, label); + const parsed = readRequiredNumber(value, label); return parsed >= 0 ? Math.floor(parsed) : undefined; } function readTimestampMs(value: unknown, label: string): number { - const timestamp = readNumber(value, label); + const timestamp = readRequiredNumber(value, label); if (Number.isNaN(new Date(timestamp).getTime())) { throw new Error(`Invalid stability bundle: ${label} must be a valid timestamp`); } @@ -283,10 +283,10 @@ function readOptionalNumber(value: unknown, label: string): number | undefined { if (value === undefined) { return undefined; } - return readNumber(value, label); + return readRequiredNumber(value, label); } -function readString(value: unknown, label: string): string { +function readRequiredString(value: unknown, label: string): string { if (typeof value !== "string") { throw new Error(`Invalid stability bundle: ${label} must be a string`); } @@ -294,7 +294,7 @@ function readString(value: unknown, label: string): string { } function readTimestampString(value: unknown, label: string): string { - const timestamp = readString(value, label); + const timestamp = readRequiredString(value, label); if (Number.isNaN(new Date(timestamp).getTime())) { throw new Error(`Invalid stability bundle: ${label} must be a valid timestamp`); } @@ -302,7 +302,7 @@ function readTimestampString(value: unknown, label: string): string { } function readCodeString(value: unknown, label: string): string { - const code = readString(value, label); + const code = readRequiredString(value, label); if (!SAFE_REASON_CODE.test(code)) { throw new Error(`Invalid stability bundle: ${label} must be a safe diagnostic code`); } @@ -313,7 +313,7 @@ function readOptionalCodeString(value: unknown, label: string): string | undefin if (value === undefined) { return undefined; } - const code = readString(value, label); + const code = readRequiredString(value, label); return SAFE_REASON_CODE.test(code) ? code : undefined; } @@ -351,11 +351,11 @@ function assignOptionalCodeString( function readMemoryUsage(value: unknown, label: string): DiagnosticMemoryUsage { const memory = readObject(value, label); return { - rssBytes: readNumber(memory.rssBytes, `${label}.rssBytes`), - heapTotalBytes: readNumber(memory.heapTotalBytes, `${label}.heapTotalBytes`), - heapUsedBytes: readNumber(memory.heapUsedBytes, `${label}.heapUsedBytes`), - externalBytes: readNumber(memory.externalBytes, `${label}.externalBytes`), - arrayBuffersBytes: readNumber(memory.arrayBuffersBytes, `${label}.arrayBuffersBytes`), + rssBytes: readRequiredNumber(memory.rssBytes, `${label}.rssBytes`), + heapTotalBytes: readRequiredNumber(memory.heapTotalBytes, `${label}.heapTotalBytes`), + heapUsedBytes: readRequiredNumber(memory.heapUsedBytes, `${label}.heapUsedBytes`), + externalBytes: readRequiredNumber(memory.externalBytes, `${label}.externalBytes`), + arrayBuffersBytes: readRequiredNumber(memory.arrayBuffersBytes, `${label}.arrayBuffersBytes`), }; } @@ -519,7 +519,7 @@ function readSessionFiles(value: unknown): DiagnosticSessionFileSummary[] | unde const files: DiagnosticSessionFileSummary[] = []; for (const [index, entry] of value.entries()) { const source = readObject(entry, `evidence.memoryPressure.topSessionFiles[${index}]`); - const relativePath = readString( + const relativePath = readRequiredString( source.relativePath, `evidence.memoryPressure.topSessionFiles[${index}].relativePath`, ); @@ -577,7 +577,7 @@ function readMemoryPressureEvidence( memory: readMemoryUsage(pressure.memory, "evidence.memoryPressure.memory"), ...(pressure.thresholdBytes !== undefined ? { - thresholdBytes: readNumber( + thresholdBytes: readRequiredNumber( pressure.thresholdBytes, "evidence.memoryPressure.thresholdBytes", ), @@ -585,14 +585,14 @@ function readMemoryPressureEvidence( : {}), ...(pressure.rssGrowthBytes !== undefined ? { - rssGrowthBytes: readNumber( + rssGrowthBytes: readRequiredNumber( pressure.rssGrowthBytes, "evidence.memoryPressure.rssGrowthBytes", ), } : {}), ...(pressure.windowMs !== undefined - ? { windowMs: readNumber(pressure.windowMs, "evidence.memoryPressure.windowMs") } + ? { windowMs: readRequiredNumber(pressure.windowMs, "evidence.memoryPressure.windowMs") } : {}), ...(heapStatistics ? { heapStatistics } : {}), ...(heapSpaces ? { heapSpaces } : {}), @@ -618,7 +618,7 @@ function readNumberMap(value: unknown, label: string): Record { if (!SAFE_REASON_CODE.test(key)) { continue; } - result[key] = readNumber(entry, `${label}.${key}`); + result[key] = readRequiredNumber(entry, `${label}.${key}`); } return result; } @@ -637,17 +637,25 @@ function readOptionalMemorySummary( return { ...(latest ? { latest } : {}), ...(memory.maxRssBytes !== undefined - ? { maxRssBytes: readNumber(memory.maxRssBytes, "snapshot.summary.memory.maxRssBytes") } + ? { + maxRssBytes: readRequiredNumber( + memory.maxRssBytes, + "snapshot.summary.memory.maxRssBytes", + ), + } : {}), ...(memory.maxHeapUsedBytes !== undefined ? { - maxHeapUsedBytes: readNumber( + maxHeapUsedBytes: readRequiredNumber( memory.maxHeapUsedBytes, "snapshot.summary.memory.maxHeapUsedBytes", ), } : {}), - pressureCount: readNumber(memory.pressureCount, "snapshot.summary.memory.pressureCount"), + pressureCount: readRequiredNumber( + memory.pressureCount, + "snapshot.summary.memory.pressureCount", + ), }; } @@ -659,10 +667,13 @@ function readOptionalPayloadLargeSummary( } const payloadLarge = readObject(value, "snapshot.summary.payloadLarge"); return { - count: readNumber(payloadLarge.count, "snapshot.summary.payloadLarge.count"), - rejected: readNumber(payloadLarge.rejected, "snapshot.summary.payloadLarge.rejected"), - truncated: readNumber(payloadLarge.truncated, "snapshot.summary.payloadLarge.truncated"), - chunked: readNumber(payloadLarge.chunked, "snapshot.summary.payloadLarge.chunked"), + count: readRequiredNumber(payloadLarge.count, "snapshot.summary.payloadLarge.count"), + rejected: readRequiredNumber(payloadLarge.rejected, "snapshot.summary.payloadLarge.rejected"), + truncated: readRequiredNumber( + payloadLarge.truncated, + "snapshot.summary.payloadLarge.truncated", + ), + chunked: readRequiredNumber(payloadLarge.chunked, "snapshot.summary.payloadLarge.chunked"), bySurface: readNumberMap(payloadLarge.bySurface, "snapshot.summary.payloadLarge.bySurface"), }; } @@ -673,7 +684,7 @@ function readStabilityEventRecord( ): DiagnosticStabilitySnapshot["events"][number] { const record = readObject(value, label); const sanitized: DiagnosticStabilitySnapshot["events"][number] = { - seq: readNumber(record.seq, `${label}.seq`), + seq: readRequiredNumber(record.seq, `${label}.seq`), ts: readTimestampMs(record.ts, `${label}.ts`), type: readCodeString( record.type, @@ -777,9 +788,9 @@ function readStabilityEventRecord( if (record.webhooks !== undefined) { const webhooks = readObject(record.webhooks, `${label}.webhooks`); sanitized.webhooks = { - received: readNumber(webhooks.received, `${label}.webhooks.received`), - processed: readNumber(webhooks.processed, `${label}.webhooks.processed`), - errors: readNumber(webhooks.errors, `${label}.webhooks.errors`), + received: readRequiredNumber(webhooks.received, `${label}.webhooks.received`), + processed: readRequiredNumber(webhooks.processed, `${label}.webhooks.processed`), + errors: readRequiredNumber(webhooks.errors, `${label}.webhooks.errors`), }; } if (record.memory !== undefined) { @@ -789,22 +800,22 @@ function readStabilityEventRecord( const usage = readObject(record.usage, `${label}.usage`); sanitized.usage = { ...(usage.input !== undefined - ? { input: readNumber(usage.input, `${label}.usage.input`) } + ? { input: readRequiredNumber(usage.input, `${label}.usage.input`) } : {}), ...(usage.output !== undefined - ? { output: readNumber(usage.output, `${label}.usage.output`) } + ? { output: readRequiredNumber(usage.output, `${label}.usage.output`) } : {}), ...(usage.cacheRead !== undefined - ? { cacheRead: readNumber(usage.cacheRead, `${label}.usage.cacheRead`) } + ? { cacheRead: readRequiredNumber(usage.cacheRead, `${label}.usage.cacheRead`) } : {}), ...(usage.cacheWrite !== undefined - ? { cacheWrite: readNumber(usage.cacheWrite, `${label}.usage.cacheWrite`) } + ? { cacheWrite: readRequiredNumber(usage.cacheWrite, `${label}.usage.cacheWrite`) } : {}), ...(usage.promptTokens !== undefined - ? { promptTokens: readNumber(usage.promptTokens, `${label}.usage.promptTokens`) } + ? { promptTokens: readRequiredNumber(usage.promptTokens, `${label}.usage.promptTokens`) } : {}), ...(usage.total !== undefined - ? { total: readNumber(usage.total, `${label}.usage.total`) } + ? { total: readRequiredNumber(usage.total, `${label}.usage.total`) } : {}), }; } @@ -812,10 +823,10 @@ function readStabilityEventRecord( const context = readObject(record.context, `${label}.context`); sanitized.context = { ...(context.limit !== undefined - ? { limit: readNumber(context.limit, `${label}.context.limit`) } + ? { limit: readRequiredNumber(context.limit, `${label}.context.limit`) } : {}), ...(context.used !== undefined - ? { used: readNumber(context.used, `${label}.context.used`) } + ? { used: readRequiredNumber(context.used, `${label}.context.used`) } : {}), }; } @@ -826,9 +837,9 @@ function readStabilityEventRecord( function readStabilitySnapshot(value: unknown): DiagnosticStabilitySnapshot { const snapshot = readObject(value, "snapshot"); const generatedAt = readTimestampString(snapshot.generatedAt, "snapshot.generatedAt"); - const capacity = readNumber(snapshot.capacity, "snapshot.capacity"); - const count = readNumber(snapshot.count, "snapshot.count"); - const dropped = readNumber(snapshot.dropped, "snapshot.dropped"); + const capacity = readRequiredNumber(snapshot.capacity, "snapshot.capacity"); + const count = readRequiredNumber(snapshot.count, "snapshot.count"); + const dropped = readRequiredNumber(snapshot.dropped, "snapshot.dropped"); const firstSeq = readOptionalNumber(snapshot.firstSeq, "snapshot.firstSeq"); const lastSeq = readOptionalNumber(snapshot.lastSeq, "snapshot.lastSeq"); if (!Array.isArray(snapshot.events)) { @@ -870,13 +881,13 @@ function parseDiagnosticStabilityBundle(value: unknown): DiagnosticStabilityBund return { version: DIAGNOSTIC_STABILITY_BUNDLE_VERSION, generatedAt: readTimestampString(bundle.generatedAt, "generatedAt"), - reason: normalizeReason(readString(bundle.reason, "reason")), + reason: normalizeReason(readRequiredString(bundle.reason, "reason")), process: { - pid: readNumber(processInfo.pid, "process.pid"), + pid: readRequiredNumber(processInfo.pid, "process.pid"), platform: readCodeString(processInfo.platform, "process.platform") as NodeJS.Platform, arch: readCodeString(processInfo.arch, "process.arch"), node: readCodeString(processInfo.node, "process.node"), - uptimeMs: readNumber(processInfo.uptimeMs, "process.uptimeMs"), + uptimeMs: readRequiredNumber(processInfo.uptimeMs, "process.uptimeMs"), }, host: { hostname: REDACTED_HOSTNAME, diff --git a/src/meeting-bot/node-host.ts b/src/meeting-bot/node-host.ts index 479ee36ccb9a..ff0400284cde 100644 --- a/src/meeting-bot/node-host.ts +++ b/src/meeting-bot/node-host.ts @@ -80,11 +80,11 @@ export type MeetingNodeHostOptions = { }; }; -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function readNumber(value: unknown, fallback: number): number { +function readPositiveNumberOr(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; } @@ -354,7 +354,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const pullAudio = async (params: Record) => { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); if (!bridgeId) { throw new Error("bridgeId required"); } @@ -362,7 +362,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { if (!session) { throw new Error(`unknown bridgeId: ${bridgeId}`); } - const timeoutMs = Math.min(readNumber(params.timeoutMs, 250), 2_000); + const timeoutMs = Math.min(readPositiveNumberOr(params.timeoutMs, 250), 2_000); if (session.chunks.length === 0 && !session.closed) { await session.waiters.wait(timeoutMs); } @@ -429,8 +429,8 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }); const pushAudio = async (params: Record) => { - const bridgeId = readString(params.bridgeId); - const base64 = readString(params.base64); + const bridgeId = readNonEmptyString(params.bridgeId); + const base64 = readNonEmptyString(params.base64); if (!bridgeId || !base64) { throw new Error("bridgeId and base64 required"); } @@ -473,7 +473,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const clearAudio = (params: Record) => { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); if (!bridgeId) { throw new Error("bridgeId required"); } @@ -503,8 +503,8 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { const startBrowser = async (params: Record) => { const url = options.normalizeUrl(params.url); - const timeoutMs = readNumber(params.joinTimeoutMs, 30_000); - const mode = readString(params.mode); + const timeoutMs = readPositiveNumberOr(params.joinTimeoutMs, 30_000); + const mode = readNonEmptyString(params.mode); let audioRuntime: MeetingAudioRuntime | undefined; let bridgeId: string | undefined; let audioBridge: @@ -550,7 +550,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { if (params.launch !== false) { const argv = ["open", "-a", options.browser.application, url]; - const browserProfile = readString(params.browserProfile); + const browserProfile = readNonEmptyString(params.browserProfile); if (browserProfile) { argv.push(...options.browser.buildProfileArgs(browserProfile)); } @@ -589,7 +589,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const bridgeStatus = (params: Record) => { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); const session = bridgeId ? sessions.get(bridgeId) : undefined; return { bridge: session @@ -625,8 +625,8 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }); const listSessions = (params: Record) => { - const urlKey = options.normalizeMeetingKey(readString(params.url)); - const mode = readString(params.mode); + const urlKey = options.normalizeMeetingKey(readNonEmptyString(params.url)); + const mode = readNonEmptyString(params.mode); const bridges = [...sessions.values()] .filter((session) => !session.stopping && !session.closed) .filter((session) => !urlKey || options.normalizeMeetingKey(session.url) === urlKey) @@ -636,12 +636,12 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const stopSessionsByUrl = async (params: Record) => { - const urlKey = options.normalizeMeetingKey(readString(params.url)); + const urlKey = options.normalizeMeetingKey(readNonEmptyString(params.url)); if (!urlKey) { throw new Error("url required"); } - const mode = readString(params.mode); - const exceptBridgeId = readString(params.exceptBridgeId); + const mode = readNonEmptyString(params.mode); + const exceptBridgeId = readNonEmptyString(params.exceptBridgeId); let stopped = 0; const stopping: Promise[] = []; for (const [bridgeId, session] of sessions) { @@ -665,7 +665,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { }; const stopBrowser = async (params: Record) => { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); if (!bridgeId) { return { ok: true, stopped: false }; } @@ -689,7 +689,7 @@ export function createMeetingNodeHost(options: MeetingNodeHostOptions): { } } const params = asOptionalRecord(raw) ?? {}; - const action = readString(params.action); + const action = readNonEmptyString(params.action); let result: unknown; switch (action) { case "setup": diff --git a/src/meeting-bot/node-invoke-policy.ts b/src/meeting-bot/node-invoke-policy.ts index 5145511a4b86..051e391e4d8b 100644 --- a/src/meeting-bot/node-invoke-policy.ts +++ b/src/meeting-bot/node-invoke-policy.ts @@ -38,7 +38,7 @@ type PolicyDecision = | { approved: true; params: Record } | { approved: false; result: OpenClawPluginNodeInvokePolicyResult }; -function readString(value: unknown): string | undefined { +function readNonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } @@ -123,7 +123,7 @@ function buildStartParams( ), }; } - const mode = readString(params.mode); + const mode = readNonEmptyString(params.mode); if (mode && !options.supportedModes.has(mode)) { return { approved: false, @@ -159,18 +159,18 @@ function buildForwardParams( params: Record, options: MeetingBrowserNodePolicyOptions, ): PolicyDecision | null { - const action = readString(params.action); + const action = readNonEmptyString(params.action); switch (action) { case "setup": return approved({ action }); case "status": { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); return approved(bridgeId ? { action, bridgeId } : { action }); } case "list": { const forwarded: Record = { action }; - const url = readString(params.url); - const mode = readString(params.mode); + const url = readNonEmptyString(params.url); + const mode = readNonEmptyString(params.mode); if (url) { try { forwarded.url = options.normalizeUrl(url); @@ -191,9 +191,9 @@ function buildForwardParams( } case "stopByUrl": { const forwarded: Record = { action }; - const url = readString(params.url); - const mode = readString(params.mode); - const exceptBridgeId = readString(params.exceptBridgeId); + const url = readNonEmptyString(params.url); + const mode = readNonEmptyString(params.mode); + const exceptBridgeId = readNonEmptyString(params.exceptBridgeId); if (!url) { return denyMissing(options, action, "url"); } @@ -218,7 +218,7 @@ function buildForwardParams( } case "pullAudio": { const forwarded: Record = { action }; - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); const timeoutMs = readPositiveNumber(params.timeoutMs); if (!bridgeId) { return denyMissing(options, action, "bridgeId"); @@ -231,8 +231,8 @@ function buildForwardParams( } case "pushAudio": { const forwarded: Record = { action }; - const bridgeId = readString(params.bridgeId); - const base64 = readString(params.base64); + const bridgeId = readNonEmptyString(params.bridgeId); + const base64 = readNonEmptyString(params.base64); if (!bridgeId) { return denyMissing(options, action, "bridgeId"); } @@ -260,7 +260,7 @@ function buildForwardParams( return approved(forwarded); } case "clearAudio": { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); if (!bridgeId) { return denyMissing(options, action, "bridgeId"); } @@ -278,7 +278,7 @@ function buildForwardParams( }); } case "stop": { - const bridgeId = readString(params.bridgeId); + const bridgeId = readNonEmptyString(params.bridgeId); return approved(bridgeId ? { action, bridgeId } : { action }); } default: @@ -297,7 +297,7 @@ export function createMeetingBrowserNodeInvokePolicy( return denied(options, `unsupported ${options.displayName} node command: ${ctx.command}`); } const params = asOptionalRecord(ctx.params) ?? {}; - const action = readString(params.action); + const action = readNonEmptyString(params.action); if (action === "setup" && options.useConfiguredSetupCommands) { const setupParams: Record = { action }; copyConfiguredAudio(setupParams, options.start); diff --git a/src/meeting-bot/realtime-node-audio-transport.ts b/src/meeting-bot/realtime-node-audio-transport.ts index 5a9391b5dbcf..fcd0208b26c2 100644 --- a/src/meeting-bot/realtime-node-audio-transport.ts +++ b/src/meeting-bot/realtime-node-audio-transport.ts @@ -10,7 +10,7 @@ const NODE_OUTPUT_GENERATION_CAPABILITY = Symbol.for( "openclaw.internal.meeting-node-output-generation.v1", ); -function readString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } @@ -82,7 +82,7 @@ export function createNodeMeetingRealtimeAudioTransport(params: { }); const rawRecord = asOptionalRecord(raw); const result = asOptionalRecord(rawRecord?.payload ?? raw) ?? {}; - const base64 = readString(result.base64); + const base64 = readNonBlankString(result.base64); if (base64) { const audio = decodeMeetingAudioBase64(base64, "pullAudio"); outputLoopbackVerifier.recordInput(audio); diff --git a/src/node-host/plugin-node-host.ts b/src/node-host/plugin-node-host.ts index c514a90d1706..d2230094fbd3 100644 --- a/src/node-host/plugin-node-host.ts +++ b/src/node-host/plugin-node-host.ts @@ -1,5 +1,6 @@ /** Plugin node-host bridge for loading plugin registry commands and dispatching node capabilities. */ import { asOptionalRecord as normalizeRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { @@ -111,10 +112,6 @@ export function watchRegisteredNodeHostCommandAvailability( }); } -function normalizeString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - function isProviderSafeToolName(value: string): boolean { return /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value); } @@ -126,13 +123,13 @@ function buildNodePluginToolDescriptor( if (!agentTool) { return null; } - const name = normalizeString(agentTool.name); - const description = normalizeString(agentTool.description); + const name = normalizeOptionalString(agentTool.name) ?? ""; + const description = normalizeOptionalString(agentTool.description) ?? ""; if (!isProviderSafeToolName(name) || !description) { return null; } - const mcpServer = normalizeString(agentTool.mcp?.server); - const mcpTool = normalizeString(agentTool.mcp?.tool); + const mcpServer = normalizeOptionalString(agentTool.mcp?.server) ?? ""; + const mcpTool = normalizeOptionalString(agentTool.mcp?.tool) ?? ""; return { pluginId: entry.pluginId, name, diff --git a/src/plugin-sdk/number-runtime.ts b/src/plugin-sdk/number-runtime.ts index 6f40bb3cb528..9cacf6b50df1 100644 --- a/src/plugin-sdk/number-runtime.ts +++ b/src/plugin-sdk/number-runtime.ts @@ -6,6 +6,7 @@ export { asFiniteNumberInRange, asSafeIntegerInRange, isFutureDateTimestampMs, + parseDateTimestampMs, parseFiniteNumber, clampTimerTimeoutMs, clampPositiveTimerTimeoutMs, diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index aa5bd2e263be..50be6e481105 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -1,6 +1,7 @@ // Provider stream shared helpers implement reusable stream wrappers and payload policies. import { resolveOpenAIReasoningEffortForModel } from "@openclaw/ai/internal/openai"; import { resolveOpenAIReasoningEffortMap } from "@openclaw/ai/transports"; +import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; import { createPromotedPlainTextToolCallBlock, createPromotedPlainTextToolCallEvents, @@ -46,10 +47,6 @@ export function composeProviderStreamWrappers( ); } -function toRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? (value as Record) : undefined; -} - function resolveContextToolNames(context: Parameters[1]): Set { const tools = (context as { tools?: unknown }).tools; if (!Array.isArray(tools)) { @@ -57,7 +54,7 @@ function resolveContextToolNames(context: Parameters[1]): Set } const names = tools .map((tool) => { - const record = toRecord(tool); + const record = asOptionalObjectRecord(tool); return typeof record?.name === "string" && record.name.trim() ? record.name : undefined; }) .filter((name): name is string => Boolean(name)); @@ -68,10 +65,10 @@ function promotePlainTextToolCalls( message: unknown, toolNames: Set, ): PlainTextToolCallMessageProjection | undefined { - const messageRecord = toRecord(message); + const messageRecord = asOptionalObjectRecord(message); if ( Array.isArray(messageRecord?.content) && - messageRecord.content.some((block) => toRecord(block)?.type === "toolCall") + messageRecord.content.some((block) => asOptionalObjectRecord(block)?.type === "toolCall") ) { return undefined; } diff --git a/src/plugin-sdk/status-helpers.test.ts b/src/plugin-sdk/status-helpers.test.ts index e8e8e57d3b73..4b3a46636665 100644 --- a/src/plugin-sdk/status-helpers.test.ts +++ b/src/plugin-sdk/status-helpers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { evaluateChannelHealth } from "../gateway/channel-health-policy.js"; import { + asString, createAsyncComputedAccountStatusAdapter, buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, @@ -19,6 +20,12 @@ import { } from "./status-helpers.js"; describe("status issue composition", () => { + it("preserves the shipped asString compatibility semantics", () => { + expect(asString(" work ")).toBe("work"); + expect(asString(" ")).toBeUndefined(); + expect(asString(42)).toBeUndefined(); + }); + it("coerces only standard and requested account fields", () => { expect( readAccountStatusSnapshot( diff --git a/src/plugin-sdk/status-helpers.ts b/src/plugin-sdk/status-helpers.ts index 1425c4cd59c5..57df8da28713 100644 --- a/src/plugin-sdk/status-helpers.ts +++ b/src/plugin-sdk/status-helpers.ts @@ -8,12 +8,13 @@ import { resolveChannelAccountState, } from "../channels/status/account-state.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +// Preserve the shipped Plugin SDK name while the implementation stays canonical. +export { normalizeOptionalString as asString }; export type { ChannelAccountSnapshot } from "../channels/plugins/types.core.js"; export type { ChannelStatusIssue } from "../channels/plugins/types.public.js"; export { isRecord } from "../channels/plugins/status-issues/shared.js"; export { appendMatchMetadata, - asString, collectIssuesForEnabledAccounts, formatMatchMetadata, resolveEnabledConfiguredAccountId, diff --git a/src/plugin-sdk/string-coerce-runtime.ts b/src/plugin-sdk/string-coerce-runtime.ts index 32081a863d0f..af3e6a71a378 100644 --- a/src/plugin-sdk/string-coerce-runtime.ts +++ b/src/plugin-sdk/string-coerce-runtime.ts @@ -19,6 +19,7 @@ export { asFiniteNumber, asPositiveSafeInteger, asSafeIntegerInRange, + parseDateTimestampMs, parseFiniteNumber, parseStrictFiniteNumber, parseStrictInteger, @@ -29,10 +30,12 @@ export { asBoolean, parseBooleanValue } from "../utils/boolean.js"; export { asRecord, asNullableRecord, + asNullableObjectRecord, asOptionalRecord, + asOptionalObjectRecord, + isRecord, readStringField, } from "../../packages/normalization-core/src/record-coerce.js"; -export { isRecord } from "../utils.js"; export { normalizeAtHashSlug, normalizeHyphenSlug, diff --git a/src/plugin-sdk/text-runtime.ts b/src/plugin-sdk/text-runtime.ts index cb4e01868980..10962cbe8bc5 100644 --- a/src/plugin-sdk/text-runtime.ts +++ b/src/plugin-sdk/text-runtime.ts @@ -13,6 +13,7 @@ export * from "../../packages/markdown-core/src/render-aware-chunking.js"; export * from "../../packages/markdown-core/src/render.js"; export * from "../../packages/markdown-core/src/tables.js"; export { resolveGlobalMap, resolveGlobalSingleton } from "../shared/global-singleton.js"; +// Public compatibility: this wildcard intentionally keeps isRecord on text-runtime. export * from "../../packages/normalization-core/src/record-coerce.js"; export * from "../shared/scoped-expiring-id-cache.js"; export * from "../../packages/normalization-core/src/string-coerce.js"; @@ -50,7 +51,6 @@ export { displayString, ensureDir, escapeRegExp, - isRecord, normalizeE164, pathExists, resolveConfigDir, diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index 87400f2999ed..75511e08e629 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { describe, expect, it } from "vitest"; import { GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA } from "../../config/bundled-channel-config-metadata.generated.js"; import { computeBaseConfigSchemaResponse } from "../../config/schema-base.js"; @@ -54,21 +55,20 @@ function collectSchemaPaths(schema: unknown, prefix = ""): string[] { return out; } -function asRecord(value: unknown): Record { - if (!value || typeof value !== "object") { +function assertRecord(value: unknown): Record { + if (!isRecord(value)) { throw new Error("expected record"); } - expect(Array.isArray(value)).toBe(false); - return value as Record; + return value; } describe("config footprint guardrails", () => { it("keeps plugin entry config generic in the generated base schema", () => { - const root = asRecord(BASE_CONFIG_SCHEMA.schema); - const plugins = asRecord(asRecord(root.properties).plugins); - const entries = asRecord(asRecord(plugins.properties).entries); - const entry = asRecord(entries.additionalProperties); - const pluginConfig = asRecord(asRecord(entry.properties).config); + const root = assertRecord(BASE_CONFIG_SCHEMA.schema); + const plugins = assertRecord(assertRecord(root.properties).plugins); + const entries = assertRecord(assertRecord(plugins.properties).entries); + const entry = assertRecord(entries.additionalProperties); + const pluginConfig = assertRecord(assertRecord(entry.properties).config); expect(pluginConfig.type).toBe("object"); expect(pluginConfig.additionalProperties).toStrictEqual({}); diff --git a/src/plugins/installed-plugin-index.test.ts b/src/plugins/installed-plugin-index.test.ts index bbee1015cc92..615f6bf64a75 100644 --- a/src/plugins/installed-plugin-index.test.ts +++ b/src/plugins/installed-plugin-index.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; // Covers installed plugin index read, write, and policy behavior. +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { PluginCandidate } from "./discovery.js"; @@ -48,10 +49,6 @@ function writeRuntimeEntry(rootDir: string) { ); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function readRecordField(record: Record, key: string, label: string) { diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index cd23f97b6991..c2c3df9c5503 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1,3 +1,4 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; /** Exercises provider runtime loading, ordering, and manifest-backed discovery paths. */ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; @@ -161,10 +162,6 @@ function createOpenAiCatalogProviderPlugin( }; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - const requireRecord = createRequireRecord("record", "expected-label-object-capitalized"); function firstMockArg(mock: { mock: { calls: unknown[][] } }): unknown { diff --git a/src/plugins/session-catalog.test.ts b/src/plugins/session-catalog.test.ts index ef784f90a15c..4368ffa9ab40 100644 --- a/src/plugins/session-catalog.test.ts +++ b/src/plugins/session-catalog.test.ts @@ -98,6 +98,8 @@ describe("importSessionCatalogHistory", () => { const { result } = importHistory( [ { id: "u-1", type: "userMessage", text: "repeat me", timestamp: "not-a-date" }, + { id: "u-2", type: "userMessage", text: "numeric year", timestamp: "2026" }, + { id: "u-3", type: "userMessage", text: "numeric zero", timestamp: "0" }, { id: "r-empty", type: "reasoning" }, { id: "r-1", @@ -118,6 +120,8 @@ describe("importSessionCatalogHistory", () => { expect(transcript.messages.map(messageText)).toEqual([ "repeat me", + "numeric year", + "numeric zero", "Thinking\n\ncareful", "answer", "Tool call\n\nbash", @@ -126,10 +130,15 @@ describe("importSessionCatalogHistory", () => { expect(transcript.messages[0]?.["__openclaw"]).toEqual({ mirrorOrigin: "pi-catalog-import", }); - expect(transcript.messages[1]?.timestamp).toBe(-1_000); - expect(transcript.messages[2]?.model).toBe("anthropic/claude"); + expect(transcript.messages[0]?.timestamp).toBe(Date.parse("2026-07-25T12:00:00.000Z")); + expect(transcript.messages[1]?.timestamp).toBe(Date.parse("2026")); + expect(transcript.messages[2]?.timestamp).toBe(Date.parse("0")); + expect(transcript.messages[3]?.timestamp).toBe(-1_000); + expect(transcript.messages[4]?.model).toBe("anthropic/claude"); expect(transcript.messages.map((message) => message.idempotencyKey)).toEqual([ "pi-catalog:thread-1:u-1", + "pi-catalog:thread-1:u-2", + "pi-catalog:thread-1:u-3", "pi-catalog:thread-1:r-1", "pi-catalog:thread-1:a-1", "pi-catalog:thread-1:t-1", diff --git a/src/skills/lifecycle/upload-store.ts b/src/skills/lifecycle/upload-store.ts index b8c549165505..e60f6087adf0 100644 --- a/src/skills/lifecycle/upload-store.ts +++ b/src/skills/lifecycle/upload-store.ts @@ -319,7 +319,7 @@ function assembleArchive( return Buffer.concat(buffers, expectedSize); } -function toRecord(row: SkillUploadRow, archivePath: string): SkillUploadRecord { +function toSkillUploadRecord(row: SkillUploadRow, archivePath: string): SkillUploadRecord { return { version: 1, kind: "skill-archive", @@ -704,7 +704,7 @@ function createSkillUploadStore(options?: SkillUploadStoreOptions) { async (tmp) => { const archivePath = path.join(tmp.dir, "archive.zip"); await fs.writeFile(archivePath, Buffer.from(row.archive_blob), { mode: 0o600 }); - return await action(toRecord(row, archivePath), { + return await action(toSkillUploadRecord(row, archivePath), { remove: async () => { // Only the callback that still owns the install lease may consume the upload. // A stalled callback must not erase a successor Gateway's replacement lease. diff --git a/src/skills/workshop/config.ts b/src/skills/workshop/config.ts index cdb4f55f1a56..8ec1181ce233 100644 --- a/src/skills/workshop/config.ts +++ b/src/skills/workshop/config.ts @@ -24,7 +24,7 @@ const DEFAULT_CONFIG: SkillWorkshopConfig = { maxSkillBytes: 40_000, }; -function readBoolean(value: unknown, fallback: boolean): boolean { +function readBooleanOr(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } @@ -52,7 +52,7 @@ export function resolveSkillWorkshopConfig(config?: OpenClawConfig): SkillWorksh autonomous: { mode: readAutonomousMode(autonomous.mode, DEFAULT_CONFIG.autonomous.mode), }, - allowSymlinkTargetWrites: readBoolean( + allowSymlinkTargetWrites: readBooleanOr( raw.allowSymlinkTargetWrites, DEFAULT_CONFIG.allowSymlinkTargetWrites, ), diff --git a/src/skills/workshop/policy.ts b/src/skills/workshop/policy.ts index 683054d7e3ec..f8417a40e41b 100644 --- a/src/skills/workshop/policy.ts +++ b/src/skills/workshop/policy.ts @@ -1,5 +1,6 @@ // Workshop policy helpers validate generated skill drafts against workspace policy. import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { getRuntimeConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; @@ -64,14 +65,6 @@ function lifecycleApprovalText(action: SkillWorkshopLifecycleAction): { }; } -function readOptionalString( - record: Record | null, - key: string, -): string | undefined { - const value = record?.[key]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function formatBodySizeKb(content: string): string { return (Buffer.byteLength(content, "utf8") / 1024).toFixed(1); } @@ -126,8 +119,8 @@ async function resolveLifecycleApprovalDescription(params: { const toolParams = asNullableRecord(params.toolParams); try { const proposal = await resolvePendingSkillProposal({ - proposalId: readOptionalString(toolParams, "proposal_id"), - name: readOptionalString(toolParams, "name"), + proposalId: normalizeOptionalString(toolParams?.proposal_id), + name: normalizeOptionalString(toolParams?.name), workspaceDir: params.workspaceDir, }); const record = proposal.record; diff --git a/src/state/openclaw-database-verify.impl.ts b/src/state/openclaw-database-verify.impl.ts index 5b675f435d54..10ee83e98cbe 100644 --- a/src/state/openclaw-database-verify.impl.ts +++ b/src/state/openclaw-database-verify.impl.ts @@ -28,7 +28,7 @@ const DATABASE_VERIFY_CHILD_ARG = "--openclaw-database-verify-child"; const ERROR_OWNED_FIELDS = new Set(["cause", "message", "name", "stack"]); const PROTOTYPE_MUTATING_FIELDS = new Set(["__proto__", "constructor", "prototype"]); -function toError(error: unknown): Error { +function toDatabaseVerifyError(error: unknown): Error { if (error instanceof Error) { return error; } @@ -104,7 +104,7 @@ export function runDatabaseVerifyWorker( stdio: ["ignore", "ignore", "ignore", "ipc"], }); } catch (error) { - return Promise.reject(toError(error)); + return Promise.reject(toDatabaseVerifyError(error)); } options.onWorker?.(worker); @@ -156,7 +156,7 @@ export function runDatabaseVerifyWorker( } result = message; }); - worker.once("error", (error) => settle(() => reject(toError(error)))); + worker.once("error", (error) => settle(() => reject(toDatabaseVerifyError(error)))); worker.once("disconnect", () => { disconnected = true; settleAfterExitAndDisconnect(); @@ -171,7 +171,7 @@ export function runDatabaseVerifyWorker( return; } worker.kill(); - settle(() => reject(toError(error))); + settle(() => reject(toDatabaseVerifyError(error))); }); }); } diff --git a/src/tasks/task-registry-activity.ts b/src/tasks/task-registry-activity.ts index 8c32fb181ed1..bfd24ef872a4 100644 --- a/src/tasks/task-registry-activity.ts +++ b/src/tasks/task-registry-activity.ts @@ -1,5 +1,8 @@ import path from "node:path"; -import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; +import { + asOptionalObjectRecord, + readStringField, +} from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { extractApplyPatchTargetPaths } from "../agents/apply-patch-paths.js"; @@ -85,15 +88,6 @@ function updateStreamText( return lastLineSnippet(cumulative); } -function readString(record: Record, keys: string[]): string | undefined { - for (const key of keys) { - if (typeof record[key] === "string") { - return record[key]; - } - } - return undefined; -} - function readTarget(record: Record): string | undefined { const target = normalizeOptionalString(record.path ?? record.file_path ?? record.filePath); return target ? path.resolve(target) : undefined; @@ -112,8 +106,8 @@ function readEditPairs(args: Record): EditPair[] { if (!edit) { continue; } - const oldText = readString(edit, ["oldText", "old_string"]); - const newText = readString(edit, ["newText", "new_string"]); + const oldText = readStringField(edit, "oldText") ?? readStringField(edit, "old_string"); + const newText = readStringField(edit, "newText") ?? readStringField(edit, "new_string"); if (oldText !== undefined && newText !== undefined) { pairs.push({ oldText, newText }); } diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index 6f24f14d9493..04d77a93df3e 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -546,6 +546,43 @@ describe("exportTrajectoryBundle", () => { expect(bundle.manifest.sourceFiles.session).toBe("$WORKSPACE_DIR/session.jsonl"); }); + it("keeps runtime timestamp validation on the Date.parse string contract", async () => { + const tmpDir = makeTempDir(); + const sessionFile = path.join(tmpDir, "session.jsonl"); + const runtimeFile = path.join(tmpDir, "session.trajectory.jsonl"); + writeSimpleSessionFile(sessionFile); + const runtimeEvents: TrajectoryEvent[] = [ + { ts: "2026", type: "date-compatible" }, + { ts: "999999", type: "numeric-milliseconds-only" }, + ].map(({ ts, type }, index) => ({ + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId: "session-1", + source: "runtime", + type, + ts, + seq: index + 1, + sourceSeq: index + 1, + sessionId: "session-1", + })); + fs.writeFileSync( + runtimeFile, + `${runtimeEvents.map((event) => JSON.stringify(event)).join("\n")}\n`, + "utf8", + ); + + const bundle = await exportTrajectoryBundle({ + outputDir: path.join(tmpDir, "bundle"), + sessionFile, + sessionId: "session-1", + workspaceDir: tmpDir, + runtimeFile, + }); + + expect(eventTypes(bundle.events)).toContain("date-compatible"); + expect(eventTypes(bundle.events)).not.toContain("numeric-milliseconds-only"); + }); + it("rejects an incomplete target that conflicts with a legacy marker", async () => { const tmpDir = makeTempDir(); diff --git a/src/trajectory/export.ts b/src/trajectory/export.ts index e27338c6ef31..b87f08dba285 100644 --- a/src/trajectory/export.ts +++ b/src/trajectory/export.ts @@ -530,7 +530,7 @@ function isRuntimeTrajectoryEvent(value: unknown): value is TrajectoryEvent { value.source === "runtime" && typeof value.type === "string" && typeof value.ts === "string" && - !Number.isNaN(Date.parse(value.ts)) && + Number.isFinite(Date.parse(value.ts)) && isFiniteNumber(value.seq) && typeof value.sessionId === "string" && (!("data" in value) || value.data === undefined || isRecord(value.data)) diff --git a/src/trajectory/runtime-store.sqlite.test.ts b/src/trajectory/runtime-store.sqlite.test.ts index 6c3c5251266d..3ee438c74159 100644 --- a/src/trajectory/runtime-store.sqlite.test.ts +++ b/src/trajectory/runtime-store.sqlite.test.ts @@ -69,6 +69,26 @@ describe("SQLite trajectory runtime store", () => { ]); }); + it.each(["2026", "0", "1969-12-31T23:59:59.000Z"])( + "stores Date.parse-compatible trajectory timestamp %s", + (timestamp) => { + appendSqliteTrajectoryRuntimeEvents({ sessionId: "session-1", storePath }, [ + createTrajectoryEvent({ ts: timestamp, type: "timestamp-contract" }), + ]); + + const database = openOpenClawAgentDatabase({ agentId: "main", path: sqlitePath() }); + const db = getNodeSqliteKysely(database.db); + const rows = executeSqliteQuerySync( + database.db, + db + .selectFrom("trajectory_runtime_events") + .select(["created_at"]) + .where("session_id", "=", "session-1"), + ).rows; + expect(rows).toEqual([{ created_at: Date.parse(timestamp) }]); + }, + ); + it("trims oldest rows beyond the configured byte window", async () => { appendSqliteTrajectoryRuntimeEvents( { maxRuntimeBytes: 900, sessionId: "session-1", storePath }, diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index fdfeb18aa9b1..b2b813cf0436 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -5,7 +5,7 @@ import { type SessionProjectionRunStatus, } from "../../packages/gateway-client/src/session-projection.js"; import { - asString, + formatPrimitiveString, extractTextFromMessage, extractTuiAbortedText, formatTuiAbortDiagnostic, @@ -568,7 +568,7 @@ export function createEventHandlers(context: EventHandlerContext) { !finalizedRuns.has(evt.runId); if ( evt.stream === "lifecycle" && - asString(evt.data?.phase, "") === "start" && + formatPrimitiveString(evt.data?.phase, "") === "start" && !finalizedRuns.has(evt.runId) && !(isLocalBtwRunId?.(evt.runId) ?? false) && matchesSelectedTuiSession(state, evt) @@ -610,9 +610,9 @@ export function createEventHandlers(context: EventHandlerContext) { return; } const data = evt.data ?? {}; - const phase = asString(data.phase, ""); - const toolCallId = asString(data.toolCallId, ""); - const toolName = asString(data.name, "tool"); + const phase = formatPrimitiveString(data.phase, ""); + const toolCallId = formatPrimitiveString(data.toolCallId, ""); + const toolName = formatPrimitiveString(data.name, "tool"); if (!toolCallId) { return; } diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 76992066a11b..1c56440a8dfe 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -748,7 +748,7 @@ export function formatContextUsageLine(params: { return `tokens ${totalLabel}/${ctxLabel}${extra ? ` (${extra})` : ""}`; } -export function asString(value: unknown, fallback = ""): string { +export function formatPrimitiveString(value: unknown, fallback = ""): string { if (typeof value === "string") { return value; } diff --git a/src/tui/tui-run-lifecycle.ts b/src/tui/tui-run-lifecycle.ts index 71bef9833a2d..640787f16b9e 100644 --- a/src/tui/tui-run-lifecycle.ts +++ b/src/tui/tui-run-lifecycle.ts @@ -1,7 +1,7 @@ // Coordinates active TUI runs, watchdogs, terminal errors, and history refresh. import { classifyFailoverReason, isAuthErrorMessage } from "../agents/embedded-agent-helpers.js"; import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.js"; -import { asString } from "./tui-formatters.js"; +import { formatPrimitiveString } from "./tui-formatters.js"; import type { TuiSessionRunCoordinator } from "./tui-session-run-coordinator.js"; import { clearPendingSubmit, @@ -179,7 +179,7 @@ export function createTuiRunLifecycle(context: TuiRunLifecycleContext) { const applyFallbackStepModelUpdate = (event: AgentEvent): boolean => { const data = event.data ?? {}; - if (event.stream !== "lifecycle" || asString(data.phase, "") !== "fallback_step") { + if (event.stream !== "lifecycle" || formatPrimitiveString(data.phase, "") !== "fallback_step") { return false; } if (typeof data.fallbackStepToModel !== "string") { diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 057a9cdcc99b..fa5554a78d15 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -14,7 +14,7 @@ import type { ChatLog } from "./components/chat-log.js"; import { refreshTuiAgentList } from "./tui-agent-list-refresh.js"; import type { TuiAgentsList, TuiBackend, TuiSessionMutationResult } from "./tui-backend.js"; import { - asString, + formatPrimitiveString, extractTextFromMessage, formatTuiErrorMessage, isCommandMessage, @@ -528,8 +528,8 @@ export function createSessionActions(context: SessionActionContext) { continue; } if (message.role === "toolResult") { - const toolCallId = asString(message.toolCallId, ""); - const toolName = asString(message.toolName, "tool"); + const toolCallId = formatPrimitiveString(message.toolCallId, ""); + const toolName = formatPrimitiveString(message.toolName, "tool"); const component = chatLog.startTool(toolCallId, toolName, {}); component.setResult( { @@ -561,8 +561,8 @@ export function createSessionActions(context: SessionActionContext) { // we stopped watching after switching away, so the persisted history above // does not contain it; render the partial and re-adopt the run so further // deltas (now that this session is active again) continue it. - const inFlightRunId = asString(record.inFlightRun?.runId, ""); - const inFlightText = asString(record.inFlightRun?.text, ""); + const inFlightRunId = formatPrimitiveString(record.inFlightRun?.runId, ""); + const inFlightText = formatPrimitiveString(record.inFlightRun?.text, ""); if (inFlightRunId) { // Render any buffered partial (embedded runtimes); Codex has none mid-run. if (inFlightText) { diff --git a/src/worker/embedded-agent.runtime.ts b/src/worker/embedded-agent.runtime.ts index 849ddfeec512..f6d8d68bc301 100644 --- a/src/worker/embedded-agent.runtime.ts +++ b/src/worker/embedded-agent.runtime.ts @@ -42,7 +42,7 @@ import { } from "./tool-authority.js"; import { WORKER_PROVIDER_REPLAY_LOCAL_RETRY_MESSAGE } from "./transcript-message.js"; -function toError(value: unknown, fallback: string): Error { +function toWorkerAgentError(value: unknown, fallback: string): Error { return value instanceof Error ? value : new Error(fallback, { cause: value }); } @@ -264,7 +264,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) let runFailure: Error | undefined; try { if (params.signal?.aborted) { - throw toError(params.signal.reason, "Worker agent turn aborted."); + throw toWorkerAgentError(params.signal.reason, "Worker agent turn aborted."); } await session.agent.prompt({ role: "user", @@ -273,7 +273,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) }); await session.agent.waitForIdle(); if (params.signal?.aborted) { - throw toError(params.signal.reason, "Worker agent turn aborted."); + throw toWorkerAgentError(params.signal.reason, "Worker agent turn aborted."); } const terminalAssistant = session.agent.state.messages .toReversed() @@ -286,8 +286,8 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) } } catch (error) { runFailure = params.signal?.aborted - ? toError(params.signal.reason, "Worker agent turn aborted.") - : toError(error, "Worker agent turn failed."); + ? toWorkerAgentError(params.signal.reason, "Worker agent turn aborted.") + : toWorkerAgentError(error, "Worker agent turn failed."); liveRuntime.enqueueRunFailure({ aborted: params.signal?.aborted === true, error: runFailure, @@ -299,7 +299,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) try { await transcriptRuntime.withSessionWriteSettlement(() => undefined); } catch (error) { - finalTranscriptFailure = toError(error, "Worker transcript flush failed."); + finalTranscriptFailure = toWorkerAgentError(error, "Worker transcript flush failed."); } await liveRuntime.flush(); if (finalTranscriptFailure === undefined) { diff --git a/src/worker/worker-connection-admission.ts b/src/worker/worker-connection-admission.ts index b603389e8496..82fa9f89f72e 100644 --- a/src/worker/worker-connection-admission.ts +++ b/src/worker/worker-connection-admission.ts @@ -16,7 +16,7 @@ import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/version.js import { WorkerAdmissionError, WorkerConnectionInterruptedError, - toError, + toWorkerConnectionError, type WorkerConnectionOptions, } from "./worker-connection-contract.js"; import { closeInvalidWorkerFrame } from "./worker-connection-frames.js"; @@ -113,7 +113,7 @@ export function connectWorkerConnectionAttempt( socket.on("error", (error) => { if (!admitted) { - rejectAttempt(new WorkerConnectionInterruptedError(toError(error).message)); + rejectAttempt(new WorkerConnectionInterruptedError(toWorkerConnectionError(error).message)); } }); socket.on("open", () => { diff --git a/src/worker/worker-connection-contract.ts b/src/worker/worker-connection-contract.ts index 71fab6e75591..1512ee2c852c 100644 --- a/src/worker/worker-connection-contract.ts +++ b/src/worker/worker-connection-contract.ts @@ -97,7 +97,7 @@ export function resolvePositiveTimeout(value: number | undefined, fallback: numb return value; } -export function toError(error: unknown): Error { +export function toWorkerConnectionError(error: unknown): Error { if (error instanceof Error) { return error; } diff --git a/src/worker/worker-connection-frames.ts b/src/worker/worker-connection-frames.ts index 8802d3480aad..cc27001bb007 100644 --- a/src/worker/worker-connection-frames.ts +++ b/src/worker/worker-connection-frames.ts @@ -32,7 +32,10 @@ import { createPendingRequestRegistry, type PendingRequestEntry, } from "../shared/pending-request-registry.js"; -import { WorkerConnectionInterruptedError, toError } from "./worker-connection-contract.js"; +import { + WorkerConnectionInterruptedError, + toWorkerConnectionError, +} from "./worker-connection-contract.js"; const WORKER_REQUEST_SPECS = { heartbeat: { @@ -195,7 +198,7 @@ export class WorkerConnectionFrameDispatcher { try { completed.value.beforeResolve?.(response); } catch (error) { - completed.reject(toError(error)); + completed.reject(toWorkerConnectionError(error)); return true; } completed.resolve(response); @@ -220,7 +223,7 @@ export class WorkerConnectionFrameDispatcher { try { encoded = JSON.stringify(frame); } catch (error) { - return Promise.reject(toError(error)); + return Promise.reject(toWorkerConnectionError(error)); } const payloadLimit = value.kind === "inference-start" @@ -254,7 +257,7 @@ export class WorkerConnectionFrameDispatcher { } catch (error) { this.pending .take(id, pending) - ?.reject(new WorkerConnectionInterruptedError(toError(error).message)); + ?.reject(new WorkerConnectionInterruptedError(toWorkerConnectionError(error).message)); this.options.interruptReadySocket(readySocket); } return pending.promise; diff --git a/src/worker/worker-connection.test.ts b/src/worker/worker-connection.test.ts index b0c0b3b49237..05a6e26f5ec2 100644 --- a/src/worker/worker-connection.test.ts +++ b/src/worker/worker-connection.test.ts @@ -13,7 +13,7 @@ import type { WorkerInferenceEventFrame, WorkerInferenceTerminalFrame, } from "../../packages/gateway-protocol/src/schema/worker-inference.js"; -import { toError } from "./worker-connection-contract.js"; +import { toWorkerConnectionError } from "./worker-connection-contract.js"; import { WorkerConnectionFrameDispatcher } from "./worker-connection-frames.js"; import { createWorkerConnection, type WorkerConnectionState } from "./worker-connection.js"; @@ -143,7 +143,7 @@ describe("worker connection error coercion", () => { original.name = "WorkerFailure"; const originalStack = original.stack; - const error = toError(original); + const error = toWorkerConnectionError(original); expect(error).toBe(original); expect(error).toMatchObject({ @@ -157,7 +157,7 @@ describe("worker connection error coercion", () => { it("preserves structured non-Error causes", () => { const cause = { code: "ECONNRESET", status: 503 }; - const error = toError(cause); + const error = toWorkerConnectionError(cause); expect(error.message).toBe("[object Object]"); expect(error.cause).toBe(cause); @@ -174,7 +174,7 @@ describe("worker connection error coercion", () => { let error: Error | undefined; expect(() => { - error = toError(cause); + error = toWorkerConnectionError(cause); }).not.toThrow(); expect(error).toMatchObject({ code: "ECONNRESET" }); expect(error).not.toHaveProperty("details"); @@ -202,7 +202,7 @@ describe("worker connection error coercion", () => { for (const handler of handlers) { const cause = new Proxy({ code: "ECONNRESET", status: 503 }, handler); - const error = toError(cause); + const error = toWorkerConnectionError(cause); expect(error).toMatchObject({ name: "Error", message: "[object Object]" }); expect(error.cause).toBe(cause); @@ -236,7 +236,7 @@ describe("worker connection error coercion", () => { [detailKey]: "symbol detail", }; - const error = toError(cause); + const error = toWorkerConnectionError(cause); expect(reservedReads).toBe(0); expect(error.message).toBe("[object Object]"); @@ -254,7 +254,7 @@ describe("worker connection error coercion", () => { enumerable: true, }); - const error = toError(cause); + const error = toWorkerConnectionError(cause); expect(Object.getPrototypeOf(error)).toBe(Error.prototype); expect(Object.hasOwn(error, "__proto__")).toBe(false); diff --git a/src/worker/worker-connection.ts b/src/worker/worker-connection.ts index 9fc9c867409e..06272e09d7ce 100644 --- a/src/worker/worker-connection.ts +++ b/src/worker/worker-connection.ts @@ -32,7 +32,7 @@ import { WorkerFencedError, isFencedCloseReason, resolvePositiveTimeout, - toError, + toWorkerConnectionError, type WorkerConnectionExit, type WorkerConnectionOptions, type WorkerConnectionState, @@ -228,7 +228,7 @@ export class WorkerConnection { this.reconnectAbort.signal, ); } catch (error) { - throw this.isTerminal() ? this.terminalError() : toError(error); + throw this.isTerminal() ? this.terminalError() : toWorkerConnectionError(error); } remainingMs = this.admissionDeadlineMs - (Date.now() - startedAt); if (remainingMs <= 0) { @@ -310,7 +310,7 @@ export class WorkerConnection { await this.connectUntilReady(); } catch (error) { if (!this.isTerminal()) { - this.finishFailed(toError(error)); + this.finishFailed(toWorkerConnectionError(error)); } } finally { this.reconnectPromise = undefined; @@ -358,7 +358,7 @@ export class WorkerConnection { } } catch (error) { if (!(error instanceof WorkerConnectionInterruptedError) && !this.isTerminal()) { - this.finishFailed(toError(error)); + this.finishFailed(toWorkerConnectionError(error)); return; } } diff --git a/src/worker/worker.runtime.test.ts b/src/worker/worker.runtime.test.ts index aa8dc9d6e602..7b01f8f0563b 100644 --- a/src/worker/worker.runtime.test.ts +++ b/src/worker/worker.runtime.test.ts @@ -3,6 +3,7 @@ import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; import path from "node:path"; import { rawDataToString } from "@openclaw/gateway-client/websocket-data"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { Type } from "typebox"; import { Value } from "typebox/value"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -120,10 +121,6 @@ type FakeGatewayOptions = { heartbeatIntervalMs?: number; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function assistantMessage( content: WorkerDoneMessage["content"], stopReason: WorkerDoneMessage["stopReason"], diff --git a/src/worker/worker.runtime.ts b/src/worker/worker.runtime.ts index a9c1d43e0f3b..854eef53c20a 100644 --- a/src/worker/worker.runtime.ts +++ b/src/worker/worker.runtime.ts @@ -23,7 +23,7 @@ export type WorkerRuntimeResult = const WORKER_REMOTE_CANCEL_GRACE_MS = 1_000; -function toError(value: unknown, fallback: string): Error { +function toWorkerRuntimeError(value: unknown, fallback: string): Error { return value instanceof Error ? value : new Error(fallback, { cause: value }); } @@ -164,7 +164,7 @@ export async function runWorkerDescriptor( signal: abortController.signal, }); if (options.signal?.aborted) { - throw toError(options.signal.reason, "worker interrupted"); + throw toWorkerRuntimeError(options.signal.reason, "worker interrupted"); } } catch (error) { const fenced = fencedResult(connection.state); @@ -172,7 +172,7 @@ export async function runWorkerDescriptor( return fenced; } if (options.signal?.aborted) { - throw toError(options.signal.reason, "worker interrupted"); + throw toWorkerRuntimeError(options.signal.reason, "worker interrupted"); } if (resultFenceAcked && connection.state.kind === "ready") { return { @@ -182,7 +182,7 @@ export async function runWorkerDescriptor( transcriptNextSeq: transcript.nextSeq, }; } - throw toError(error, "worker session failed"); + throw toWorkerRuntimeError(error, "worker session failed"); } const fenced = fencedResult(connection.state); if (fenced) { diff --git a/test/e2e/qa-lab/runtime/cloud-worker-disappearance-proof.ts b/test/e2e/qa-lab/runtime/cloud-worker-disappearance-proof.ts index 4737f7b5f393..d2d95aa1c65b 100644 --- a/test/e2e/qa-lab/runtime/cloud-worker-disappearance-proof.ts +++ b/test/e2e/qa-lab/runtime/cloud-worker-disappearance-proof.ts @@ -31,7 +31,7 @@ type ProducerOptions = { artifactBase: string; repoRoot: string }; type Gateway = Awaited>; type SessionIdentity = { agentId: string; sessionId: string; sessionKey: string }; -function asRecord(value: unknown, label: string): Record { +function requireRecord(value: unknown, label: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} was not an object`); } @@ -74,13 +74,13 @@ async function createQaSession( senderId: string, marker: string, ): Promise { - const beforePayload = asRecord( + const beforePayload = requireRecord( await gateway.call("sessions.list", { limit: 100 }), "sessions.list before inbound", ); const beforeIds = new Set( (Array.isArray(beforePayload.sessions) ? beforePayload.sessions : []).flatMap((entry) => { - const row = asRecord(entry, "session row"); + const row = requireRecord(entry, "session row"); return typeof row.sessionId === "string" ? [row.sessionId] : []; }), ); @@ -92,9 +92,12 @@ async function createQaSession( text: `Reply exactly: ${marker}`, }); await waitForOutbound(state, cursor, marker); - const payload = asRecord(await gateway.call("sessions.list", { limit: 100 }), "sessions.list"); + const payload = requireRecord( + await gateway.call("sessions.list", { limit: 100 }), + "sessions.list", + ); const sessions = Array.isArray(payload.sessions) ? payload.sessions : []; - const rows = sessions.map((entry) => asRecord(entry, "session row")); + const rows = sessions.map((entry) => requireRecord(entry, "session row")); const session = rows.find((entry) => typeof entry.sessionId === "string" && !beforeIds.has(entry.sessionId)) ?? rows.find((entry) => @@ -203,12 +206,12 @@ function seedUnknownWorkerState( } async function describePlacement(gateway: Gateway, session: SessionIdentity) { - const payload = asRecord( + const payload = requireRecord( await gateway.call("sessions.describe", { key: session.sessionKey }), "sessions.describe", ); - const row = asRecord(payload.session, "described session"); - return asRecord(row.placement, "session placement"); + const row = requireRecord(payload.session, "described session"); + return requireRecord(row.placement, "session placement"); } async function waitForFailedPlacement(gateway: Gateway, session: SessionIdentity) { @@ -301,15 +304,17 @@ async function runProof(options: ProducerOptions) { } } - const environmentPayload = asRecord( + const environmentPayload = requireRecord( await gateway.call("environments.list", {}), "environments.list", ); const environments = Array.isArray(environmentPayload.environments) - ? environmentPayload.environments.map((entry) => asRecord(entry, "environment")) + ? environmentPayload.environments.map((entry) => requireRecord(entry, "environment")) : []; const environment = environments.find((entry) => entry.id === ENVIRONMENT_ID); - const worker = environment ? asRecord(environment.worker, "worker environment") : undefined; + const worker = environment + ? requireRecord(environment.worker, "worker environment") + : undefined; if (worker?.state !== "orphaned" || typeof worker.error !== "string") { throw new Error("environment-level orphaned diagnostic was not preserved"); } diff --git a/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts b/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts index 9c31b83754e6..b75827b71e77 100644 --- a/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts +++ b/test/e2e/qa-lab/runtime/gateway-rpc-account-health.ts @@ -62,7 +62,7 @@ export function withSiblingAccount(config: OpenClawConfig, baseUrl?: string): Op }; } -function asRecord(value: unknown): Record { +function assertRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`expected record, got ${JSON.stringify(value)}`); } @@ -70,33 +70,33 @@ function asRecord(value: unknown): Record { } function readHealthAccounts(payload: unknown): Record { - const channels = asRecord(asRecord(payload).channels); - const channel = asRecord(channels[CHANNEL_ID]); - const accounts = asRecord(channel.accounts); + const channels = assertRecord(assertRecord(payload).channels); + const channel = assertRecord(channels[CHANNEL_ID]); + const accounts = assertRecord(channel.accounts); return Object.fromEntries( Object.entries(accounts).map(([accountId, state]) => [ accountId, - asRecord(state) as AccountState, + assertRecord(state) as AccountState, ]), ); } function readStatusAccounts(payload: unknown): Record { - const channelAccounts = asRecord(asRecord(payload).channelAccounts); + const channelAccounts = assertRecord(assertRecord(payload).channelAccounts); const accounts = channelAccounts[CHANNEL_ID]; if (!Array.isArray(accounts)) { throw new Error(`channels.status omitted ${CHANNEL_ID} accounts`); } return Object.fromEntries( accounts.map((state) => { - const record = asRecord(state) as AccountState; + const record = assertRecord(state) as AccountState; return [String(record.accountId), record]; }), ); } export function statusSummaryMentions(payload: unknown, ...needles: string[]) { - const channelSummary = asRecord(payload).channelSummary; + const channelSummary = assertRecord(payload).channelSummary; if (!Array.isArray(channelSummary) || channelSummary.some((line) => typeof line !== "string")) { throw new Error(`status omitted channelSummary lines: ${JSON.stringify(payload)}`); } @@ -127,8 +127,8 @@ async function waitForAccounts( } function readChannelConfig(payload: unknown) { - const config = asRecord(asRecord(payload).config); - return structuredClone(asRecord(asRecord(config.channels)[CHANNEL_ID])); + const config = assertRecord(assertRecord(payload).config); + return structuredClone(assertRecord(assertRecord(config.channels)[CHANNEL_ID])); } async function waitForAppliedConfig( @@ -137,7 +137,7 @@ async function waitForAppliedConfig( ) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { - const payload = asRecord(await gateway.call("config.get", {})); + const payload = assertRecord(await gateway.call("config.get", {})); if ( payload.hash === hash && typeof payload.appliedConfigHash === "string" && @@ -178,10 +178,10 @@ export async function runGatewayRpcAccountHealthProof( const initialHealth = await gateway.call("health", { probe: true }); const initialStatus = await gateway.call("status", { includeChannelSummary: true }); const initialAccounts = readHealthAccounts(initialHealth); - const beforeConfig = asRecord(await gateway.call("config.get", {})); + const beforeConfig = assertRecord(await gateway.call("config.get", {})); const beforeChannelConfig = readChannelConfig(beforeConfig); - const patchResult = asRecord( + const patchResult = assertRecord( await gateway.call("config.patch", { raw: JSON.stringify({ channels: { @@ -202,9 +202,9 @@ export async function runGatewayRpcAccountHealthProof( const appliedConfig = await waitForAppliedConfig(gateway, patchResult.hash); const afterChannelConfig = readChannelConfig(appliedConfig); const expectedChannelConfig = structuredClone(beforeChannelConfig); - const expectedAccounts = asRecord(expectedChannelConfig.accounts); + const expectedAccounts = assertRecord(expectedChannelConfig.accounts); expectedAccounts[TARGET_ACCOUNT_ID] = { - ...asRecord(expectedAccounts[TARGET_ACCOUNT_ID]), + ...assertRecord(expectedAccounts[TARGET_ACCOUNT_ID]), enabled: false, }; @@ -222,7 +222,7 @@ export async function runGatewayRpcAccountHealthProof( const finalAccounts = readHealthAccounts(finalHealth); return { - initialHealthOk: asRecord(initialHealth).ok === true, + initialHealthOk: assertRecord(initialHealth).ok === true, initialStatusVisible: statusSummaryMentions(initialStatus, CHANNEL_LABEL, "default", TARGET_ACCOUNT_ID) && initialChannelAccounts.default?.running === true && diff --git a/test/helpers/gateway/android-node-capabilities-policy-config.ts b/test/helpers/gateway/android-node-capabilities-policy-config.ts index be7e4f59a3c7..ab856cfdb018 100644 --- a/test/helpers/gateway/android-node-capabilities-policy-config.ts +++ b/test/helpers/gateway/android-node-capabilities-policy-config.ts @@ -1,13 +1,9 @@ // Android node capability policy config fixture describes gateway policy config. +import { asRecord } from "@openclaw/normalization-core/record-coerce"; import type { OpenClawConfig } from "../../../src/config/config.js"; // Test helper for unwrapping gateway config.get response shapes. -/** Narrow unknown payloads to plain records for fixture parsing. */ -function asRecord(value: unknown): Record { - return typeof value === "object" && value !== null ? (value as Record) : {}; -} - /** Unwrap current and legacy remote config snapshot envelopes. */ export function unwrapRemoteConfigSnapshot(raw: unknown): OpenClawConfig { const rawObj = asRecord(raw); diff --git a/test/helpers/openclaw-test-instance.ts b/test/helpers/openclaw-test-instance.ts index 13632704e3a8..35ab3a117d10 100644 --- a/test/helpers/openclaw-test-instance.ts +++ b/test/helpers/openclaw-test-instance.ts @@ -5,6 +5,7 @@ import fs from "node:fs/promises"; import net from "node:net"; import path from "node:path"; import type { Readable } from "node:stream"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { BUILD_STAMP_FILE, RUNTIME_POSTBUILD_STAMP_FILE, @@ -395,10 +396,6 @@ function hasChildExited(child: Pick { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function mergeConfig( base: Record, override: Record | undefined, diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 852d2beb28d1..71ffb7ad7156 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -1417,6 +1417,7 @@ describe("scripts/changed-lanes", () => { "guarded extension wildcard re-exports", "plugin-sdk wildcard re-exports", "duplicate scan target coverage", + "coercion helper declaration guard", "dependency pin guard", "format changed files", "deprecated API usage", @@ -1600,6 +1601,7 @@ describe("scripts/changed-lanes", () => { "lint:extensions:no-guarded-wildcard-reexports", "lint:extensions:no-plugin-sdk-wildcard-reexports", "dup:check:coverage", + "check:coercion-helpers", "deps:pins:check", "format:check", "--import", @@ -2352,6 +2354,7 @@ describe("scripts/changed-lanes", () => { args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"], }, { name: "duplicate scan target coverage", args: ["dup:check:coverage"] }, + { name: "coercion helper declaration guard", args: ["check:coercion-helpers"] }, { name: "dependency pin guard", args: ["deps:pins:check"] }, { name: "package patch guard", args: ["deps:patches:check"] }, ]); @@ -2375,6 +2378,7 @@ describe("scripts/changed-lanes", () => { args: ["lint:extensions:no-plugin-sdk-wildcard-reexports"], }, { name: "duplicate scan target coverage", args: ["dup:check:coverage"] }, + { name: "coercion helper declaration guard", args: ["check:coercion-helpers"] }, { name: "dependency pin guard", args: ["deps:pins:check"] }, { name: "format changed files", diff --git a/test/scripts/check-coercion-helper-declarations.test.ts b/test/scripts/check-coercion-helper-declarations.test.ts new file mode 100644 index 000000000000..49aa24600495 --- /dev/null +++ b/test/scripts/check-coercion-helper-declarations.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { + auditCoercionHelperDeclarations, + findBannedCoercionHelperDeclarations, + isGovernedCoercionHelperPath, + type CoercionHelperCarveOut, + type CoercionHelperDeclaration, +} from "../../scripts/check-coercion-helper-declarations.mts"; + +describe("coercion helper declaration AST guard", () => { + it("finds nested, exported, async, and callable-variable declarations", () => { + const source = [ + "export async function readString() {}", + "if (true) {", + " function asRecord() {}", + "}", + "const isRecord = (value: unknown) => Boolean(value);", + "let toError = function (value: unknown) { return value; };", + "var optionalString = async (value: unknown) => value;", + "const readString = (((value: unknown) => String(value)) satisfies ((value: unknown) => string));", + "function readNumber(record: Record, key: string) { return record[key]; }", + "const timestampMs = (value: unknown) => Number(value);", + "function readBoolean() {}", + "function readOptionalString() {}", + "function normalizeString() {}", + "const asString = (value: unknown) => String(value);", + "function asObject() {}", + "const readOptionalString = normalizeOptionalString;", + "const optionalString = helpers.readStringValue;", + "const asObject = helpers.asOptionalRecord;", + ].join("\n"); + + expect(findBannedCoercionHelperDeclarations(source, "src/example.ts")).toEqual([ + { file: "src/example.ts", kind: "function", line: 1, name: "readString" }, + { file: "src/example.ts", kind: "function", line: 3, name: "asRecord" }, + { file: "src/example.ts", kind: "variable", line: 5, name: "isRecord" }, + { file: "src/example.ts", kind: "variable", line: 6, name: "toError" }, + { file: "src/example.ts", kind: "variable", line: 7, name: "optionalString" }, + { file: "src/example.ts", kind: "variable", line: 8, name: "readString" }, + { file: "src/example.ts", kind: "function", line: 9, name: "readNumber" }, + { file: "src/example.ts", kind: "variable", line: 10, name: "timestampMs" }, + { file: "src/example.ts", kind: "function", line: 11, name: "readBoolean" }, + { file: "src/example.ts", kind: "function", line: 12, name: "readOptionalString" }, + { file: "src/example.ts", kind: "function", line: 13, name: "normalizeString" }, + { file: "src/example.ts", kind: "variable", line: 14, name: "asString" }, + { file: "src/example.ts", kind: "function", line: 15, name: "asObject" }, + { file: "src/example.ts", kind: "variable", line: 16, name: "readOptionalString" }, + { file: "src/example.ts", kind: "variable", line: 17, name: "optionalString" }, + { file: "src/example.ts", kind: "variable", line: 18, name: "asObject" }, + ]); + }); + + it("ignores imports, aliases, methods, properties, callbacks, and inert text", () => { + const source = [ + 'import { isRecord, readString as importedReadString } from "./helpers.js";', + "const alias = isRecord;", + "const { asRecord } = helpers;", + "class Example { isRecord() {} readString = () => true; }", + "const object = { optionalString() {}, toError: () => new Error() };", + "values.map(function readString(value) { return value; });", + "const aliasWithInternalName = function isRecord(value) { return value; };", + "const asRecord = raw as Record;", + "const optionalString = value as string;", + "// function asRecord() {}", + 'const fixture = "function toError() {}";', + ].join("\n"); + + expect(findBannedCoercionHelperDeclarations(source, "src/example.ts")).toEqual([]); + }); + + it("checks exact counts and rejects excess, stale, or malformed carve-outs", () => { + const declarations: CoercionHelperDeclaration[] = [ + { file: "src/allowed.ts", kind: "function", line: 2, name: "isRecord" }, + { file: "src/allowed.ts", kind: "variable", line: 8, name: "isRecord" }, + { file: "src/new.ts", kind: "function", line: 4, name: "readString" }, + ]; + const carveOuts: CoercionHelperCarveOut[] = [ + { + file: "src/allowed.ts", + name: "isRecord", + count: 1, + reason: "Dependency-free protocol boundary.", + }, + { + file: "src/removed.ts", + name: "toError", + count: 1, + reason: "Hostile object trap semantics.", + }, + { file: "src/blank.ts", name: "asRecord", count: 0, reason: "" }, + ]; + + expect(auditCoercionHelperDeclarations(declarations, carveOuts)).toEqual({ + excessDeclarations: [ + { file: "src/allowed.ts", kind: "variable", line: 8, name: "isRecord" }, + { file: "src/new.ts", kind: "function", line: 4, name: "readString" }, + ], + invalidCarveOuts: [ + "src/blank.ts [asRecord] must have a positive count", + "src/blank.ts [asRecord] needs a non-empty reason", + ], + staleCarveOuts: [ + { + file: "src/removed.ts", + name: "toError", + count: 1, + reason: "Hostile object trap semantics.", + actualCount: 0, + lines: [], + }, + ], + }); + }); + + it("excludes declarations, fixtures, generated sources, and browser bundles", () => { + expect(isGovernedCoercionHelperPath("src/runtime.ts")).toBe(true); + expect(isGovernedCoercionHelperPath("extensions/demo/runtime.jsx")).toBe(true); + expect(isGovernedCoercionHelperPath("src/runtime.d.ts")).toBe(false); + expect(isGovernedCoercionHelperPath("scripts/runtime.d.mts")).toBe(false); + expect(isGovernedCoercionHelperPath("test/fixtures/example.ts")).toBe(false); + expect(isGovernedCoercionHelperPath("extensions/demo/dist/index.js")).toBe(false); + expect(isGovernedCoercionHelperPath("src/example.test-fixtures.ts")).toBe(false); + expect(isGovernedCoercionHelperPath("src/schema.generated.ts")).toBe(false); + expect(isGovernedCoercionHelperPath("ui/src/vendor.bundle.js")).toBe(false); + expect( + isGovernedCoercionHelperPath( + "extensions/browser/chrome-extension/modules/copilot-runtime.js", + ), + ).toBe(false); + }); +}); diff --git a/test/scripts/check-file-utils.test.ts b/test/scripts/check-file-utils.test.ts index 7342fd848159..89d94964e85e 100644 --- a/test/scripts/check-file-utils.test.ts +++ b/test/scripts/check-file-utils.test.ts @@ -92,6 +92,7 @@ describe("scripts/check-file-utils listRepoFilesSync", () => { expect.objectContaining({ timeout: 30_000, killSignal: "SIGKILL", + maxBuffer: 64 * 1024 * 1024, }), ); }); diff --git a/test/scripts/check-memory-fd-repro.test.ts b/test/scripts/check-memory-fd-repro.test.ts index 47277d596b71..4aa06bdf5212 100644 --- a/test/scripts/check-memory-fd-repro.test.ts +++ b/test/scripts/check-memory-fd-repro.test.ts @@ -14,8 +14,8 @@ import { hasChildExited, invokeMemorySearch, parseArgs, + parseNonNegativeInteger, readBoundedResponseText, - readNumber, readPositiveNumber, stopGatewayWithRuntime, updateGatewayReadyOutputState, @@ -42,13 +42,19 @@ async function listen(server: Server): Promise { describe("check-memory-fd-repro", () => { it("parses file, fd, and timing limits as strict integers", () => { - expect(readNumber("0", "limit")).toBe(0); - expect(readNumber(" 42 ", "limit")).toBe(42); + expect(parseNonNegativeInteger("0", "limit")).toBe(0); + expect(parseNonNegativeInteger(" 42 ", "limit")).toBe(42); expect(readPositiveNumber("1", "limit")).toBe(1); - expect(() => readNumber("1.5", "limit")).toThrow("limit must be a non-negative integer"); - expect(() => readNumber("1e3", "limit")).toThrow("limit must be a non-negative integer"); - expect(() => readNumber("10files", "limit")).toThrow("limit must be a non-negative integer"); + expect(() => parseNonNegativeInteger("1.5", "limit")).toThrow( + "limit must be a non-negative integer", + ); + expect(() => parseNonNegativeInteger("1e3", "limit")).toThrow( + "limit must be a non-negative integer", + ); + expect(() => parseNonNegativeInteger("10files", "limit")).toThrow( + "limit must be a non-negative integer", + ); expect(() => readPositiveNumber("0", "limit")).toThrow("limit must be greater than 0"); }); diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 7aa439e77d3f..7cf449698bc8 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -4896,6 +4896,14 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" ); expect(npmLockGuards).toContain("pnpm deps:npm-lock:check"); expect(preflightGuards).toContain("pnpm deps:patches:check"); + expect(preflightGuards).toContain('has_package_script "check:coercion-helpers"'); + expect(preflightGuards).toContain("pnpm check:coercion-helpers"); + expect(preflightGuards).toContain( + "[skip] historical target predates the coercion-helper declaration guard", + ); + expect(preflightGuards).toContain( + "Current CI targets must provide the check:coercion-helpers package script.", + ); expect(parsedWorkflow.jobs.preflight.outputs.diff_base_revision).toBe( "${{ steps.diff_base.outputs.sha }}", ); diff --git a/test/scripts/k8s-manifests.test.ts b/test/scripts/k8s-manifests.test.ts index 388165b3f1f6..8bebeaf060e3 100644 --- a/test/scripts/k8s-manifests.test.ts +++ b/test/scripts/k8s-manifests.test.ts @@ -13,7 +13,7 @@ function readManifest(name: string): Manifest { return parsed as Manifest; } -function asRecord(value: unknown, label: string): Record { +function assertRecord(value: unknown, label: string): Record { expect(value, label).toBeTypeOf("object"); expect(value, label).not.toBeNull(); expect(Array.isArray(value), label).toBe(false); @@ -58,14 +58,14 @@ describe("k8s manifests", () => { it("keeps gateway service selectors and ports aligned with deployment labels", () => { const deployment = readManifest("deployment.yaml"); const service = readManifest("service.yaml"); - const deploymentSpec = asRecord(deployment.spec, "deployment spec"); - const selector = asRecord(deploymentSpec.selector, "deployment selector"); - const matchLabels = asRecord(selector.matchLabels, "deployment match labels"); - const template = asRecord(deploymentSpec.template, "deployment template"); - const templateMetadata = asRecord(template.metadata, "deployment template metadata"); - const templateLabels = asRecord(templateMetadata.labels, "deployment template labels"); - const serviceSpec = asRecord(service.spec, "service spec"); - const serviceSelector = asRecord(serviceSpec.selector, "service selector"); + const deploymentSpec = assertRecord(deployment.spec, "deployment spec"); + const selector = assertRecord(deploymentSpec.selector, "deployment selector"); + const matchLabels = assertRecord(selector.matchLabels, "deployment match labels"); + const template = assertRecord(deploymentSpec.template, "deployment template"); + const templateMetadata = assertRecord(template.metadata, "deployment template metadata"); + const templateLabels = assertRecord(templateMetadata.labels, "deployment template labels"); + const serviceSpec = assertRecord(service.spec, "service spec"); + const serviceSelector = assertRecord(serviceSpec.selector, "service selector"); const ports = asRecords(serviceSpec.ports, "service ports"); expect(deployment).toMatchObject({ @@ -86,14 +86,14 @@ describe("k8s manifests", () => { it("keeps deployment mounts, secrets, and security posture deployable", () => { const deployment = readManifest("deployment.yaml"); - const spec = asRecord(deployment.spec, "deployment spec"); - const template = asRecord(spec.template, "deployment template"); - const podSpec = asRecord(template.spec, "pod spec"); + const spec = assertRecord(deployment.spec, "deployment spec"); + const template = assertRecord(spec.template, "deployment template"); + const podSpec = assertRecord(template.spec, "pod spec"); const containers = asRecords(podSpec.containers, "containers"); const gateway = findNamed(containers, "gateway"); const env = asRecords(gateway.env, "gateway env"); const volumes = asRecords(podSpec.volumes, "pod volumes"); - const securityContext = asRecord(gateway.securityContext, "gateway security context"); + const securityContext = assertRecord(gateway.securityContext, "gateway security context"); expect(gateway.command).toEqual(["node", "/app/dist/index.js", "gateway", "run"]); expect(findNamed(env, "HOME")).toMatchObject({ value: "/home/node" }); @@ -115,15 +115,15 @@ describe("k8s manifests", () => { it("keeps config and persistence manifests aligned with the gateway", () => { const configMap = readManifest("configmap.yaml"); const pvc = readManifest("pvc.yaml"); - const data = asRecord(configMap.data, "configmap data"); + const data = assertRecord(configMap.data, "configmap data"); const config = JSON.parse(String(data["openclaw.json"])) as Record; - const gateway = asRecord(config.gateway, "openclaw config gateway"); - const auth = asRecord(gateway.auth, "openclaw config auth"); - const agents = asRecord(config.agents, "openclaw config agents"); - const defaults = asRecord(agents.defaults, "openclaw config agent defaults"); - const pvcSpec = asRecord(pvc.spec, "pvc spec"); - const resources = asRecord(pvcSpec.resources, "pvc resources"); - const requests = asRecord(resources.requests, "pvc resource requests"); + const gateway = assertRecord(config.gateway, "openclaw config gateway"); + const auth = assertRecord(gateway.auth, "openclaw config auth"); + const agents = assertRecord(config.agents, "openclaw config agents"); + const defaults = assertRecord(agents.defaults, "openclaw config agent defaults"); + const pvcSpec = assertRecord(pvc.spec, "pvc spec"); + const resources = assertRecord(pvcSpec.resources, "pvc resources"); + const requests = assertRecord(resources.requests, "pvc resource requests"); expect(configMap).toMatchObject({ apiVersion: "v1", diff --git a/ui/src/components/browser/browser-client.ts b/ui/src/components/browser/browser-client.ts index 66af5d6d02d0..307ceb4b0443 100644 --- a/ui/src/components/browser/browser-client.ts +++ b/ui/src/components/browser/browser-client.ts @@ -5,6 +5,7 @@ // locally or via a browser-capable node. This module narrows the handful of // routes the browser panel needs and keeps route-path knowledge in one place. import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import { t } from "../../i18n/index.ts"; @@ -66,26 +67,26 @@ function browserRequest(client: GatewayBrowserClient, envelope: BrowserReques return client.request(BROWSER_REQUEST_METHOD, envelope); } -function asString(value: unknown): string { - return typeof value === "string" ? value : ""; +function stringOrEmpty(value: unknown): string { + return readStringValue(value) ?? ""; } -function asFiniteNumber(value: unknown): number | null { +function asNullableFiniteNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } function normalizeTab(value: unknown): BrowserPanelTab | null { const record = asRecord(value); - const targetId = asString(record?.targetId); + const targetId = stringOrEmpty(record?.targetId); if (!targetId) { return null; } - const tabId = asString(record?.tabId); + const tabId = stringOrEmpty(record?.tabId); return { id: tabId || targetId, targetId, - title: asString(record?.title), - url: asString(record?.url), + title: stringOrEmpty(record?.title), + url: stringOrEmpty(record?.url), }; } @@ -129,8 +130,8 @@ export async function navigateBrowser( await browserRequest(client, { method: "POST", path: "/navigate", body: params }), ); return { - targetId: asString(result?.targetId) || params.targetId || "", - url: asString(result?.url) || params.url, + targetId: stringOrEmpty(result?.targetId) || params.targetId || "", + url: stringOrEmpty(result?.url) || params.url, }; } @@ -145,14 +146,14 @@ export async function captureBrowserScreenshot( body: { targetId, type: "png" }, }), ); - const path = asString(result?.path); + const path = stringOrEmpty(result?.path); if (!path) { throw new Error(t("browser.errors.screenshotPathMissing")); } return { path, - targetId: asString(result?.targetId) || targetId, - url: asString(result?.url), + targetId: stringOrEmpty(result?.targetId) || targetId, + url: stringOrEmpty(result?.url), }; } @@ -235,16 +236,16 @@ export async function readBrowserPageMetrics( fn: "() => ({ cssWidth: window.innerWidth, cssHeight: window.innerHeight, title: document.title, url: location.href })", }), ); - const cssWidth = asFiniteNumber(result?.cssWidth); - const cssHeight = asFiniteNumber(result?.cssHeight); + const cssWidth = asNullableFiniteNumber(result?.cssWidth); + const cssHeight = asNullableFiniteNumber(result?.cssHeight); if (!cssWidth || !cssHeight || cssWidth <= 0 || cssHeight <= 0) { return null; } return { cssWidth, cssHeight, - title: asString(result?.title), - url: asString(result?.url), + title: stringOrEmpty(result?.title), + url: stringOrEmpty(result?.url), }; } @@ -284,18 +285,18 @@ export async function inspectBrowserElementAt( } const rect = asRecord(result.rect); return { - tag: asString(result.tag), - id: asString(result.id), + tag: stringOrEmpty(result.tag), + id: stringOrEmpty(result.id), classes: Array.isArray(result.classes) ? result.classes.filter((value): value is string => typeof value === "string") : [], - role: asString(result.role), - name: asString(result.name), + role: stringOrEmpty(result.role), + name: stringOrEmpty(result.name), rect: { - x: asFiniteNumber(rect?.x) ?? 0, - y: asFiniteNumber(rect?.y) ?? 0, - width: asFiniteNumber(rect?.width) ?? 0, - height: asFiniteNumber(rect?.height) ?? 0, + x: asNullableFiniteNumber(rect?.x) ?? 0, + y: asNullableFiniteNumber(rect?.y) ?? 0, + width: asNullableFiniteNumber(rect?.width) ?? 0, + height: asNullableFiniteNumber(rect?.height) ?? 0, }, focusable: result.focusable === true, }; diff --git a/ui/src/components/github-link-hovercard.ts b/ui/src/components/github-link-hovercard.ts index 6b5eb2a02c22..a86142da9621 100644 --- a/ui/src/components/github-link-hovercard.ts +++ b/ui/src/components/github-link-hovercard.ts @@ -46,7 +46,10 @@ function requiredString(record: Record, key: string): string { return value; } -function optionalString(record: Record, key: string): string | undefined { +function readOptionalGitHubString( + record: Record, + key: string, +): string | undefined { const value = record[key]; return typeof value === "string" && value.trim() ? value : undefined; } @@ -122,19 +125,19 @@ function parsePreviewResponse(target: GitHubLinkTarget, value: unknown): GitHubP additions: optionalNumber(value, "additions"), avatarDataUrl: safeAvatarDataUrl(value.avatarDataUrl), changedFiles: optionalNumber(value, "changedFiles"), - closedAt: optionalString(value, "closedAt"), + closedAt: readOptionalGitHubString(value, "closedAt"), comments: optionalNumber(value, "comments"), createdAt: requiredString(value, "createdAt"), deletions: optionalNumber(value, "deletions"), draft: typeof value.draft === "boolean" ? value.draft : undefined, kind: target.kind, - login: optionalString(value, "login") ?? "ghost", - mergedAt: optionalString(value, "mergedAt"), + login: readOptionalGitHubString(value, "login") ?? "ghost", + mergedAt: readOptionalGitHubString(value, "mergedAt"), number: target.number, owner: target.owner, repo: target.repo, state: requiredString(value, "state"), - stateReason: optionalString(value, "stateReason"), + stateReason: readOptionalGitHubString(value, "stateReason"), title: requiredString(value, "title"), updatedAt: requiredString(value, "updatedAt"), }; diff --git a/ui/src/e2e/control-ui-auth-transports.e2e.test.ts b/ui/src/e2e/control-ui-auth-transports.e2e.test.ts index 6254ac3188b0..1aad2e912905 100644 --- a/ui/src/e2e/control-ui-auth-transports.e2e.test.ts +++ b/ui/src/e2e/control-ui-auth-transports.e2e.test.ts @@ -3,6 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage } from "node:http"; import net from "node:net"; import path from "node:path"; +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { WebSocket, WebSocketServer, type RawData } from "ws"; @@ -92,12 +93,6 @@ let gateway: RealGateway; let proxy: RealTransportProxy; const openContexts = new Set(); -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - function stringValue(value: unknown): string | null { return typeof value === "string" ? value : null; } @@ -115,7 +110,7 @@ function parseJsonFrame(data: RawData): Record | null { : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : data.toString("utf8"); - return asRecord(JSON.parse(text)); + return asNullableRecord(JSON.parse(text)); } catch { return null; } @@ -128,14 +123,14 @@ function captureBrowserConnect( if (frame.type !== "req" || frame.method !== "connect") { return null; } - const params = asRecord(frame.params); - const client = asRecord(params?.client); - const auth = asRecord(params?.auth); + const params = asNullableRecord(frame.params); + const client = asNullableRecord(params?.client); + const auth = asNullableRecord(params?.auth); evidence.browserConnect = { authFields: auth ? Object.keys(auth).toSorted() : [], clientId: stringValue(client?.id), clientMode: stringValue(client?.mode), - hasDevice: asRecord(params?.device) !== null, + hasDevice: asNullableRecord(params?.device) !== null, scopes: stringArray(params?.scopes).toSorted(), }; return stringValue(frame.id); @@ -149,10 +144,10 @@ function captureGatewayResult( if (frame.type !== "res" || stringValue(frame.id) !== connectRequestId) { return; } - const error = asRecord(frame.error); - const details = asRecord(error?.details); - const payload = asRecord(frame.payload); - const auth = asRecord(payload?.auth); + const error = asNullableRecord(frame.error); + const details = asNullableRecord(error?.details); + const payload = asNullableRecord(frame.payload); + const auth = asNullableRecord(payload?.auth); evidence.gatewayResult = { errorCode: stringValue(details?.code) ?? stringValue(error?.code), errorReason: stringValue(details?.authReason) ?? stringValue(details?.reason), diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index 573a60be3ddd..a6d5580dc3d9 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -74,7 +74,7 @@ describe("i18n", () => { }); } - function readString(value: unknown, path: string): string { + function readTranslationString(value: unknown, path: string): string { let cursor = value; for (const part of path.split(".")) { cursor = @@ -193,7 +193,7 @@ describe("i18n", () => { await vi.waitFor(() => expect(fresh.i18n.getLocale()).toBe(expectedLocale)); expect(fresh.t("common.health")).toBe( - readString(expectedLocale === "zh-TW" ? zh_TW : zh_CN, "common.health"), + readTranslationString(expectedLocale === "zh-TW" ? zh_TW : zh_CN, "common.health"), ); }, ); @@ -266,7 +266,9 @@ describe("i18n", () => { zh_TW, })) { for (const key of checkedKeys) { - expect(readString(value, key), `${locale}:${key}`).not.toBe(readString(en, key)); + expect(readTranslationString(value, key), `${locale}:${key}`).not.toBe( + readTranslationString(en, key), + ); } } }); @@ -278,7 +280,9 @@ describe("i18n", () => { for (const [locale, value] of Object.entries(shippedLocales)) { for (const key of checkedKeys) { - expect(readString(value, key), `${locale}:${key}`).not.toBe(readString(en, key)); + expect(readTranslationString(value, key), `${locale}:${key}`).not.toBe( + readTranslationString(en, key), + ); } } }); @@ -288,7 +292,9 @@ describe("i18n", () => { for (const [locale, value] of Object.entries(shippedLocales)) { for (const key of checkedKeys) { - expect(readString(value, key), `${locale}:${key}`).not.toBe(readString(en, key)); + expect(readTranslationString(value, key), `${locale}:${key}`).not.toBe( + readTranslationString(en, key), + ); } } }); diff --git a/ui/src/lib/board/widget-bridge.ts b/ui/src/lib/board/widget-bridge.ts index 114b115bf09c..45c8c57b5263 100644 --- a/ui/src/lib/board/widget-bridge.ts +++ b/ui/src/lib/board/widget-bridge.ts @@ -34,7 +34,7 @@ export function isBoardWidgetBridgeRequest(value: unknown): value is BoardWidget ); } -function asRecord(value: unknown): Record { +function assertWidgetRequestRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error("widget host request params are invalid"); } @@ -133,7 +133,7 @@ export class BoardWidgetBridgeController { if (request.ticket !== this.ticket) { throw new Error("widget view ticket does not match the active frame"); } - const params = asRecord(request.params); + const params = assertWidgetRequestRecord(request.params); switch (request.method) { case "prompt.send": { if (options.promptUserActivated !== true) { diff --git a/ui/src/lib/chat/tool-call-patch.ts b/ui/src/lib/chat/tool-call-patch.ts index e7682dbb952f..3b9b2c88cd28 100644 --- a/ui/src/lib/chat/tool-call-patch.ts +++ b/ui/src/lib/chat/tool-call-patch.ts @@ -36,7 +36,7 @@ type PatchViewData = { move?: { from: string; to: string }; }; -function readString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } @@ -371,13 +371,13 @@ function parseStructuredPatch(changes: unknown[]): PatchViewData | null { const collector: PatchCollector = { sections: [], storedRows: 0, truncated: false }; for (const value of changes) { const record = asRecord(value); - const sourcePath = readString(record?.path)?.trim(); + const sourcePath = readNonBlankString(record?.path)?.trim(); if (!record || !sourcePath) { continue; } const operation = readOperation(record.kind); const section = startSection(collector, operation, sourcePath); - const movePath = readString( + const movePath = readNonBlankString( asRecord(record.kind)?.move_path ?? asRecord(record.kind)?.movePath, ); if (movePath) { @@ -413,7 +413,10 @@ export function parsePatchView(args: unknown): PatchViewData | null { return structured; } } - const text = readString(record.patch) ?? readString(record.input) ?? readString(record.diff); + const text = + readNonBlankString(record.patch) ?? + readNonBlankString(record.input) ?? + readNonBlankString(record.diff); if (!text) { return null; } diff --git a/ui/src/lib/chat/tool-call-view.ts b/ui/src/lib/chat/tool-call-view.ts index 36070f3bb17f..f63f85820b66 100644 --- a/ui/src/lib/chat/tool-call-view.ts +++ b/ui/src/lib/chat/tool-call-view.ts @@ -57,7 +57,7 @@ const SEARCH_TOOL_NAMES = new Set(["grep", "find", "glob", "ls", "list", "codeba const FETCH_TOOL_NAMES = new Set(["web_fetch", "webfetch", "fetch"]); const PATCH_TOOL_NAMES = new Set(["apply_patch", "applypatch", "patch"]); -function readString(value: unknown): string | undefined { +function readNonBlankString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } @@ -66,13 +66,13 @@ function resolvePathArg(args: Record | null): string | undefine return undefined; } return ( - readString(args.path) ?? - readString(args.file_path) ?? - readString(args.filePath) ?? - readString(args.file) ?? - readString(args.filepath) ?? - readString(args.filename) ?? - readString(args.notebook_path) + readNonBlankString(args.path) ?? + readNonBlankString(args.file_path) ?? + readNonBlankString(args.filePath) ?? + readNonBlankString(args.file) ?? + readNonBlankString(args.filepath) ?? + readNonBlankString(args.filename) ?? + readNonBlankString(args.notebook_path) ); } @@ -136,7 +136,7 @@ function readEditPairs(args: Record): { pairs: EditPair[]; trun function readDetailsDiff(details: unknown): ResolvedEditDiff | null { const record = asRecord(details); - const diffText = record ? readString(record.diff) : undefined; + const diffText = record ? readNonBlankString(record.diff) : undefined; if (!diffText) { return null; } @@ -200,7 +200,7 @@ function resolveInsertionDiff( if (fromDetails) { return fromDetails; } - const insertText = args ? readString(args.insert_text) : undefined; + const insertText = args ? readNonBlankString(args.insert_text) : undefined; if (!insertText) { return null; } @@ -256,7 +256,7 @@ function normalizeKey(name: string): string { type TextEditorCommand = "view" | "str_replace" | "create" | "insert" | "undo_edit"; function resolveTextEditorCommand(args: unknown): TextEditorCommand | undefined { - const command = readString(asRecord(args)?.command)?.trim().toLowerCase(); + const command = readNonBlankString(asRecord(args)?.command)?.trim().toLowerCase(); switch (command) { case "view": case "str_replace": @@ -367,7 +367,7 @@ function buildToolCallView( : undefined; if (kind === "command") { - const command = args ? readString(args.command) : undefined; + const command = args ? readNonBlankString(args.command) : undefined; return { kind, command: command ? unwrapShellWrapperCommand(command) : command }; } @@ -425,8 +425,8 @@ function buildToolCallView( } const content = args ? editorCommand === "create" - ? readString(args.file_text) - : readString(args.content) + ? readNonBlankString(args.file_text) + : readNonBlankString(args.content) : undefined; if (!content) { return { kind, target: base, targetDetail: dir }; @@ -446,9 +446,11 @@ function buildToolCallView( if (kind === "search") { const pattern = args - ? (readString(args.pattern) ?? readString(args.query) ?? readString(args.glob)) + ? (readNonBlankString(args.pattern) ?? + readNonBlankString(args.query) ?? + readNonBlankString(args.glob)) : undefined; - const path = resolvePathArg(args) ?? (args ? readString(args.path) : undefined); + const path = resolvePathArg(args) ?? (args ? readNonBlankString(args.path) : undefined); if (!pattern && !path) { return { kind: "generic" }; } @@ -456,7 +458,7 @@ function buildToolCallView( } if (kind === "fetch") { - const url = args ? readString(args.url) : undefined; + const url = args ? readNonBlankString(args.url) : undefined; if (!url) { return { kind: "generic" }; } diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 502c2de4421a..7d95d07ae487 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -955,6 +955,8 @@ function installControlUiMockGateway( return names; } + // This function is serialized with installControlUiMockGateway.toString(). + // Keep the guard local so the generated script captures no module imports. function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }