mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
069f6e1c34
commit
1220a7609a
@@ -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,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,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,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,
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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,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;
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user