refactor: consolidate promise-like guards (#121861)

* feat(normalization-core): add promise-like guard

* refactor: consolidate promise-like guards

* fix(normalization-core): keep isPromiseLike non-throwing on hostile then getters

ClawSweeper finding on #121861: the diagnostics-path local guard caught throwing
then getters; the canonical guard must classify, never throw.

* test(normalization-core): annotate intentional hostile-thenable fixture
This commit is contained in:
Peter Steinberger
2026-08-10 22:50:48 -07:00
committed by GitHub
parent 069f6e1c34
commit 1220a7609a
24 changed files with 79 additions and 85 deletions
+6 -1
View File
@@ -54,6 +54,11 @@
"import": "./dist/phone-presentation.mjs",
"default": "./dist/phone-presentation.mjs"
},
"./promise-like": {
"types": "./dist/promise-like.d.mts",
"import": "./dist/promise-like.mjs",
"default": "./dist/promise-like.mjs"
},
"./record-coerce": {
"types": "./dist/record-coerce.d.mts",
"import": "./dist/record-coerce.mjs",
@@ -91,7 +96,7 @@
}
},
"scripts": {
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/json-schema.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/stable-node-path.ts src/stable-stringify.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/cjk-chars.ts src/error-coercion.ts src/expect.ts src/json-schema.ts src/number-coercion.ts src/phone-presentation.ts src/promise-like.ts src/record-coerce.ts src/result.ts src/stable-node-path.ts src/stable-stringify.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"libphonenumber-js": "1.13.9",
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { isPromiseLike } from "./promise-like.js";
describe("isPromiseLike", () => {
it("accepts thenables and rejects values without a callable then", () => {
const thenable = {};
// oxlint-disable-next-line unicorn/no-thenable -- An explicit thenable is the contract under test.
Reflect.defineProperty(thenable, "then", { value: () => {} });
const nonCallableThen = {};
// oxlint-disable-next-line unicorn/no-thenable -- The guard must reject a non-callable then field.
Reflect.defineProperty(nonCallableThen, "then", { value: true });
expect(isPromiseLike(Promise.resolve())).toBe(true);
expect(isPromiseLike(thenable)).toBe(true);
expect(isPromiseLike(nonCallableThen)).toBe(false);
expect(isPromiseLike(null)).toBe(false);
});
});
it("classifies objects with throwing then getters as non-thenable", () => {
// oxlint-disable-next-line unicorn/no-thenable -- intentionally hostile thenable fixture
const hostile = Object.defineProperty({}, "then", {
get() {
throw new Error("trap");
},
});
expect(isPromiseLike(hostile)).toBe(false);
});
@@ -0,0 +1,13 @@
/** Canonical thenable guard; use instead of local isPromiseLike copies. */
export function isPromiseLike<T = unknown>(value: unknown): value is PromiseLike<T> {
if (value === null || (typeof value !== "object" && typeof value !== "function")) {
return false;
}
// A hostile/exotic object can throw from its `then` getter; a guard must
// classify, never throw (diagnostics streams rely on this).
try {
return typeof (value as { then?: unknown }).then === "function";
} catch {
return false;
}
}
+1 -7
View File
@@ -1,4 +1,5 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import type { AnyAgentTool } from "./tools/common.js";
type AgentRingZeroToolScope = {
@@ -8,13 +9,6 @@ type AgentRingZeroToolScope = {
const activeRingZeroTools = new AsyncLocalStorage<AgentRingZeroToolScope>();
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
if ((typeof value !== "object" || value === null) && typeof value !== "function") {
return false;
}
return "then" in value && typeof value.then === "function";
}
class HostScopedAgentToolAuthorizationError extends Error {
readonly status = 403;
+1 -7
View File
@@ -1,4 +1,5 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import {
createCronCreatorAuthorityRunScope,
mintCronCreatorAuthorityGrant,
@@ -21,13 +22,6 @@ const activeCronCreatorAuthority = new AsyncLocalStorage<CronCreatorAuthorityRun
const activeCronCreatorAuthorityResolver =
new AsyncLocalStorage<CronCreatorAuthorityResolverScope>();
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
if ((typeof value !== "object" || value === null) && typeof value !== "function") {
return false;
}
return "then" in value && typeof value.then === "function";
}
/** Keeps fresh cron reauthorization within one admitted Gateway agent run. */
export function runWithCronCreatorAuthority<T>(
runId: string,
@@ -1,3 +1,4 @@
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
/**
@@ -417,17 +418,6 @@ function modelCallSizeTimingFields(state: ModelCallObservationState): ModelCallS
};
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
if (value === null || (typeof value !== "object" && typeof value !== "function")) {
return false;
}
try {
return typeof (value as { then?: unknown }).then === "function";
} catch {
return false;
}
}
function asyncIteratorFactory(value: unknown): (() => AsyncIterator<unknown>) | undefined {
if (value === null || typeof value !== "object") {
return undefined;
@@ -1,4 +1,4 @@
import { isPromiseLike } from "./embedded-agent-subscribe.promise.js";
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
type CallbackLogger = {
warn(message: string): void;
@@ -12,7 +12,7 @@ export function runBestEffortCallback(params: {
}): void {
try {
const result = params.callback();
if (isPromiseLike<unknown>(result)) {
if (isPromiseLike(result)) {
void Promise.resolve(result).catch((error: unknown) => {
params.log.warn(`${params.label} callback failed: ${String(error)}`);
});
@@ -1,6 +1,7 @@
/**
* Handles lifecycle and compaction events from subscribed embedded-agent sessions.
*/
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { createInlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { hasAcceptedSessionSpawn } from "./accepted-session-spawn.js";
@@ -26,7 +27,6 @@ import {
hasAssistantVisibleReply,
} from "./embedded-agent-subscribe.handlers.messages.js";
import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js";
import { isPromiseLike } from "./embedded-agent-subscribe.promise.js";
import { isAssistantMessage } from "./embedded-agent-utils.js";
import type { AgentSessionEvent } from "./sessions/index.js";
import { summarizeToolValidationError } from "./tool-error-summary.js";
@@ -1,3 +1,4 @@
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
/**
* Handles embedded-agent assistant message events, block replies, reasoning
* streams, reply directives, and pending tool media attachment handoff.
@@ -34,7 +35,6 @@ import type {
EmbeddedAgentSubscribeContext,
EmbeddedAgentSubscribeState,
} from "./embedded-agent-subscribe.handlers.types.js";
import { isPromiseLike } from "./embedded-agent-subscribe.promise.js";
import { appendRawStream } from "./embedded-agent-subscribe.raw-stream.js";
import { warnIfAssistantEmittedSuspiciousText } from "./embedded-agent-subscribe.tool-text-diagnostics.js";
import {
@@ -1,3 +1,4 @@
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
/**
* Handles embedded-agent tool execution events and turns them into channel UI,
* replay state, hook calls, approval prompts, media queues, and agent-event
@@ -69,7 +70,6 @@ import type {
ToolCallSummary,
ToolHandlerContext,
} from "./embedded-agent-subscribe.handlers.types.js";
import { isPromiseLike } from "./embedded-agent-subscribe.promise.js";
import {
collectMessagingMediaUrlsFromRecord,
collectMessagingMediaUrlsFromToolResult,
@@ -1,6 +1,7 @@
/**
* Dispatches serialized embedded-agent subscription events to specific handlers.
*/
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import {
handleAgentEnd,
handleAgentStart,
@@ -24,7 +25,6 @@ import type {
EmbeddedAgentSubscribeContext,
EmbeddedAgentSubscribeEvent,
} from "./embedded-agent-subscribe.handlers.types.js";
import { isPromiseLike } from "./embedded-agent-subscribe.promise.js";
import type { AgentMessage } from "./runtime/index.js";
/** Create the serialized event dispatcher for subscribed embedded-agent sessions. */
@@ -1,9 +0,0 @@
/** Narrow unknown values to PromiseLike without requiring a concrete Promise. */
export function isPromiseLike<T>(value: unknown): value is PromiseLike<T> {
return Boolean(
value &&
(typeof value === "object" || typeof value === "function") &&
"then" in value &&
typeof (value as { then?: unknown }).then === "function",
);
}
+1 -1
View File
@@ -1,6 +1,7 @@
/**
* Subscribes to embedded-agent sessions and streams formatted replies/events.
*/
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { InlineCodeState } from "../../packages/markdown-core/src/code-spans.js";
@@ -51,7 +52,6 @@ import type {
EmbeddedAgentSubscribeContext,
EmbeddedAgentSubscribeState,
} from "./embedded-agent-subscribe.handlers.types.js";
import { isPromiseLike } from "./embedded-agent-subscribe.promise.js";
import {
buildToolLifecycleErrorResult,
extractToolResultMediaArtifact,
+1 -4
View File
@@ -3,6 +3,7 @@
*
* Converts route, sender, command, media, and supplemental facts into finalized message context.
*/
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import {
commandTurnKindToSource,
createCommandTurnContext,
@@ -251,10 +252,6 @@ function definedFields<T extends Record<string, unknown>>(fields: T): Partial<T>
) as Partial<T>;
}
function isPromiseLike<T>(value: MaybePromise<T>): value is Promise<T> {
return Boolean(value) && typeof (value as { then?: unknown }).then === "function";
}
function stripQuoteRuntimeFields(
quote: ChannelInboundSupplementalQuoteFacts,
): NonNullable<SupplementalContextFacts["quote"]> {
@@ -1,5 +1,6 @@
// Approval shared helpers normalize pending exec/plugin approval lookups,
// decision payloads, turn-source routing, and gateway error responses.
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
import type {
@@ -76,10 +77,6 @@ type ApprovalResolveParamsValidator<TParams extends ApprovalResolveParams> = ((
errors?: ValidationError[] | null;
};
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value;
}
function isApprovalDecision(value: string): value is ExecApprovalDecision {
return value === "allow-once" || value === "allow-always" || value === "deny";
}
+1 -4
View File
@@ -1,5 +1,6 @@
// Provides SQLite transaction helpers with nested savepoints.
import type { DatabaseSync } from "node:sqlite";
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { createSubsystemLogger, type SubsystemLogger } from "../logging/subsystem.js";
// The cache-state module keeps this lifecycle edge off the kysely value graph
// so cold control-plane paths using transactions do not load kysely.
@@ -37,10 +38,6 @@ function nextSavepointName(): string {
return `openclaw_tx_${nextSavepointId}`;
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return Boolean(value && typeof (value as { then?: unknown }).then === "function");
}
function assertSyncTransactionResult(value: unknown): void {
if (isPromiseLike(value)) {
throw new Error(
+1 -8
View File
@@ -15,6 +15,7 @@ import {
applyOpenAIResponsesPayloadPolicy,
resolveOpenAIResponsesPayloadPolicy,
} from "@openclaw/ai/transports";
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
// OpenAI stream wrapper normalizes OpenAI-compatible streamed tool and text events.
import {
@@ -134,14 +135,6 @@ function isCodeModeEnabled(config?: OpenClawConfig): boolean {
);
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
value !== null &&
(typeof value === "object" || typeof value === "function") &&
typeof (value as { then?: unknown }).then === "function"
);
}
function filterCodeModePayloadHookResult(
payload: unknown,
nextPayload: unknown,
+1 -4
View File
@@ -1,6 +1,7 @@
// Model-backed image understanding runtime for providers without a native media
// provider hook.
import { clampPositiveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { isMinimaxVlmModel, minimaxUnderstandImage } from "../agents/minimax-vlm.js";
import { requireApiKey, resolveApiKeyForProvider } from "../agents/model-auth.js";
@@ -93,10 +94,6 @@ function isImageModelNoTextError(err: unknown): boolean {
return err instanceof Error && /^Image model returned no text\b/.test(err.message);
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return Boolean(value) && typeof (value as { then?: unknown }).then === "function";
}
function composeImageDescriptionPayloadHandlers(
first: ProviderStreamOptions["onPayload"] | undefined,
second: ProviderStreamOptions["onPayload"] | undefined,
+1 -7
View File
@@ -7,6 +7,7 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { clampPositiveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { isToolAllowedByPolicyName } from "../agents/tool-policy-match.js";
import {
attachToolAllowlistIntersection,
@@ -615,13 +616,6 @@ export function createHookRunner(
const getPluginPackageVersion = (pluginId: string): string | undefined =>
registry.plugins.find((plugin) => plugin.id === pluginId)?.packageVersion;
const isPromiseLike = (value: unknown): value is PromiseLike<unknown> => {
if ((typeof value !== "object" && typeof value !== "function") || value === null) {
return false;
}
return typeof (value as { then?: unknown }).then === "function";
};
const normalizePositiveTimeoutMs = (timeoutMs: number | undefined): number | undefined => {
return clampPositiveTimerTimeoutMs(timeoutMs);
};
+1 -4
View File
@@ -1,5 +1,6 @@
// Tracks host hook state and scheduled turn identifiers.
import { randomUUID } from "node:crypto";
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { SessionEntry } from "../config/sessions.js";
import {
@@ -464,10 +465,6 @@ function collectPluginSessionExtensionProjections(params: {
return projections;
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return Boolean(value && typeof (value as { then?: unknown }).then === "function");
}
function discardUnexpectedPromiseProjection(value: PromiseLike<unknown>): void {
void Promise.resolve(value).catch(() => undefined);
}
+1 -8
View File
@@ -1,3 +1,4 @@
import { isPromiseLike } from "@openclaw/normalization-core/promise-like";
import { toSafeImportPath } from "../shared/import-specifier.js";
import { attachPluginApiFacades } from "./api-facades.js";
import { isLateCallablePluginApiMethod } from "./api-lifecycle.js";
@@ -41,14 +42,6 @@ const LAZY_RUNTIME_REFLECTION_KEYS = [
"llm",
] as const satisfies readonly (keyof PluginRuntime)[];
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return (
(typeof value === "object" || typeof value === "function") &&
value !== null &&
typeof (value as { then?: unknown }).then === "function"
);
}
function createGuardedPluginRegistrationApi(api: OpenClawPluginApi): {
api: OpenClawPluginApi;
close: () => void;
+1
View File
@@ -12,6 +12,7 @@ function restoreMocks(mocks: readonly RestorableMock[]): void {
}
}
// This guard requires concrete Promise.finally narrowing for synchronous cleanup overloads.
function isPromiseLike<T>(value: T | Promise<T>): value is Promise<T> {
return (
typeof value === "object" &&
+10
View File
@@ -482,6 +482,16 @@ export const sharedVitestConfig = {
"phone-presentation.ts",
),
},
{
find: "@openclaw/normalization-core/promise-like",
replacement: path.join(
repoRoot,
"packages",
"normalization-core",
"src",
"promise-like.ts",
),
},
{
find: "@openclaw/normalization-core/record-coerce",
replacement: path.join(
+3
View File
@@ -164,6 +164,9 @@
"@openclaw/normalization-core/phone-presentation": [
"./packages/normalization-core/src/phone-presentation.ts"
],
"@openclaw/normalization-core/promise-like": [
"./packages/normalization-core/src/promise-like.ts"
],
"@openclaw/normalization-core/record-coerce": [
"./packages/normalization-core/src/record-coerce.ts"
],