refactor(types): remove chained assertions in runtime boundaries (#124082)

This commit is contained in:
Peter Steinberger
2026-08-15 01:03:26 -07:00
committed by GitHub
parent 250b1e68be
commit f4871eb86b
34 changed files with 196 additions and 162 deletions
@@ -17,7 +17,7 @@ export function createTestFollowupRun(overrides: Partial<FollowupRun["run"]> = {
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
skillsSnapshot: { prompt: "", skills: [] },
provider: "anthropic",
model: "claude",
thinkLevel: "low",
@@ -29,7 +29,7 @@ export function createTestFollowupRun(overrides: Partial<FollowupRun["run"]> = {
skipProviderRuntimeHints: true,
...overrides,
},
} as unknown as FollowupRun;
} satisfies FollowupRun;
}
export async function writeTestSessionStore(
+20 -5
View File
@@ -1,4 +1,5 @@
/** Loads, normalizes, quarantines, and persists cron service store state. */
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeCronJobIdentityFields } from "../normalize-job-identity.js";
import { normalizeCronJobInput } from "../normalize.js";
import { getInvalidPersistedCronJobReason } from "../persisted-shape.js";
@@ -134,6 +135,12 @@ function warnInvalidPersistedCronJob(params: {
);
}
function isValidatedCronJob(
value: Record<string, unknown>,
): value is CronJob & Record<string, unknown> {
return getInvalidPersistedCronJobReason(value) === null;
}
/** Loads and normalizes the cron store, quarantining invalid persisted rows before runtime use. */
export async function ensureLoaded(
state: CronServiceState,
@@ -163,7 +170,7 @@ export async function ensureLoaded(
const loadNowMs = state.deps.nowMs();
// Persisted cron rows are validated lazily, so treat them as raw records at the
// store boundary and only trust the CronJob shape after validation below.
const loadedJobs = (loaded.store.jobs ?? []) as unknown as Record<string, unknown>[];
const loadedJobs = (loaded.store.jobs ?? []).filter(isRecord);
const jobs: CronJob[] = [];
const durableNextRunAtMsByJobId = new Map<string, number | undefined>();
const quarantinedConfigJobs: QuarantinedCronConfigJob[] = [...loaded.invalidConfigRows];
@@ -190,11 +197,16 @@ export async function ensureLoaded(
}
const hydratedRaw = normalized ?? raw;
let invalidReason = rawInvalidReason ?? getInvalidPersistedCronJobReason(hydratedRaw);
const hydratedSchedule = (hydratedRaw.schedule ?? {}) as Record<string, unknown>;
if (!invalidReason && hydratedRaw.enabled !== false && hydratedSchedule.kind === "every") {
const hydratedSchedule = isRecord(hydratedRaw.schedule) ? hydratedRaw.schedule : {};
if (
!invalidReason &&
isValidatedCronJob(hydratedRaw) &&
hydratedRaw.enabled &&
hydratedSchedule.kind === "every"
) {
try {
assertTimeScheduleSatisfiable(
{ ...(hydratedRaw as unknown as CronJob), state: {} },
{ ...hydratedRaw, state: {} },
loadNowMs,
computeJobNextRunAtMs,
);
@@ -226,7 +238,10 @@ export async function ensureLoaded(
continue;
}
// Validated above, so the raw record is now a trusted CronJob.
const hydrated = hydratedRaw as unknown as CronJob;
if (!isValidatedCronJob(hydratedRaw)) {
continue;
}
const hydrated = hydratedRaw;
jobs.push(hydrated);
// Capture the value SQLite actually held before schedule-identity repair
// mutates the runtime view. A later save can then publish that transition.
+3 -3
View File
@@ -193,13 +193,13 @@ function bindCronJobRow(storeKey: string, job: CronStoredJob, sortOrder: number)
job_json: JSON.stringify(stripJobRuntimeFields(job)),
state_json: JSON.stringify(job.state ?? {}),
runtime_updated_at_ms: job.updatedAtMs,
schedule_identity: tryCronScheduleIdentity(job as unknown as Record<string, unknown>) ?? null,
schedule_identity: tryCronScheduleIdentity({ ...job }) ?? null,
sort_order: sortOrder,
};
}
function normalizeCronJobForSqlite(job: CronStoreFile["jobs"][number]): CronStoredJob | null {
const raw = structuredClone(job) as unknown as Record<string, unknown>;
const raw: Record<string, unknown> = { ...structuredClone(job) };
const hadDeleteAfterRun = Object.hasOwn(raw, "deleteAfterRun");
normalizeCronJobIdentityFields(raw);
const normalized = normalizeCronJobInput(raw, { applyDefaults: true });
@@ -570,7 +570,7 @@ export function updateCronRuntimeRows(
...bindStateColumns(job.state ?? {}),
state_json: JSON.stringify(job.state ?? {}),
runtime_updated_at_ms: job.updatedAtMs,
schedule_identity: tryCronScheduleIdentity(job as unknown as Record<string, unknown>),
schedule_identity: tryCronScheduleIdentity({ ...job }),
})
.where("store_key", "=", storeKey)
.where("job_id", "=", job.id),
+13 -7
View File
@@ -161,7 +161,7 @@ type PendingEntry<TPayload = ExecApprovalRequestPayload> = {
record: ExecApprovalRecord<TPayload>;
resolve: (decision: ExecApprovalDecision | null) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
timer: ReturnType<typeof setTimeout> | null;
cleanupTimer: ReturnType<typeof setTimeout> | null;
handoffRetainCount: number;
handoffReleasedAtMs: number | null;
@@ -367,7 +367,7 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
record,
resolve: resolvePromise!,
reject: rejectPromise!,
timer: null as unknown as ReturnType<typeof setTimeout>,
timer: null,
cleanupTimer: null,
handoffRetainCount: 0,
handoffReleasedAtMs: null,
@@ -757,7 +757,9 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
if (!pending || pending.record.resolvedAtMs !== undefined) {
return false;
}
clearTimeout(pending.timer);
if (pending.timer) {
clearTimeout(pending.timer);
}
pending.record.resolvedAtMs = params.resolvedAtMs;
if (params.decision === null) {
delete pending.record.decision;
@@ -875,12 +877,16 @@ export class ExecApprovalManager<TPayload = ExecApprovalRequestPayload> {
}
private scheduleExpiryTimer(entry: PendingEntry<TPayload>): void {
const timerDelayMs = resolveApprovalTimeoutMs(entry.record.expiresAtMs - Date.now());
entry.timer = setTimeout(() => {
entry.timer = this.createExpiryTimer(entry.record);
}
private createExpiryTimer(record: ExecApprovalRecord<TPayload>): ReturnType<typeof setTimeout> {
const timerDelayMs = resolveApprovalTimeoutMs(record.expiresAtMs - Date.now());
return setTimeout(() => {
try {
this.expireDue(entry.record.id);
this.expireDue(record.id);
} catch (error) {
this.reportError(error, { approvalId: entry.record.id, operation: "expire" });
this.reportError(error, { approvalId: record.id, operation: "expire" });
}
}, timerDelayMs);
}
+2 -2
View File
@@ -21,7 +21,7 @@ export type McpToolSchemaEntry = {
function readLoopbackToolField(tool: McpLoopbackTool, key: "name" | "description" | "parameters") {
try {
return (tool as unknown as Record<typeof key, unknown>)[key];
return tool[key];
} catch {
return undefined;
}
@@ -45,7 +45,7 @@ function readLoopbackToolDescription(tool: McpLoopbackTool): string | undefined
function readLoopbackToolParameters(tool: McpLoopbackTool): Record<string, unknown> | undefined {
let value;
try {
value = (tool as unknown as { parameters?: unknown }).parameters;
value = tool.parameters;
} catch {
return undefined;
}
+2 -2
View File
@@ -116,15 +116,15 @@ export class QuestionManager {
expiresAtMs,
status: "pending",
};
const expiryTimer = setTimeout(() => this.expire(record.id), timeoutMs);
const entry: QuestionEntry = {
record,
expiryTimer: null as unknown as ReturnType<typeof setTimeout>,
expiryTimer,
cleanupTimer: null,
waiters: new Set(),
onResolved: params.onResolved,
};
this.entries.set(record.id, entry);
entry.expiryTimer = setTimeout(() => this.expire(record.id), timeoutMs);
unrefTimer(entry.expiryTimer);
return record;
}
+5 -1
View File
@@ -888,6 +888,10 @@ export const cronHandlers: GatewayRequestHandlers = {
if (!assertValidParams(candidate, validateCronUpdateParams, "cron.update", respond)) {
return;
}
if (!normalizedPatch) {
respondInvalidCronParams(respond, "cron.update", "patch did not normalize");
return;
}
const p = candidate as {
id?: string;
jobId?: string;
@@ -912,7 +916,7 @@ export const cronHandlers: GatewayRequestHandlers = {
);
return;
}
const patch = p.patch as unknown as CronJobPatch;
const patch: CronJobPatch = normalizedPatch;
const cfg = context.getRuntimeConfig();
const currentJob = await context.cron.readJob(jobId);
if (
@@ -4,7 +4,7 @@ import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import { compactEmbeddedAgentSession } from "../../agents/embedded-agent.js";
import { resolveManualCompactionCliTarget } from "../../agents/session-runtime-compat.js";
import { preflightManualSessionCompaction } from "../../agents/sessions/manual-compaction-preflight.js";
import type { SessionEntry as AgentSessionEntry } from "../../agents/sessions/session-manager.js";
import { isIndexedSessionEntry } from "../../agents/sessions/session-manager-codec.js";
import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js";
import { normalizeReasoningLevel, normalizeThinkLevel } from "../../auto-reply/thinking.js";
import type { SessionEntry } from "../../config/sessions.js";
@@ -14,7 +14,6 @@ import {
resolveSessionTranscriptRuntimeTarget,
} from "../../config/sessions/session-accessor.js";
import {
isCanonicalSessionTranscriptEntry,
scanSessionTranscriptTree,
selectSessionTranscriptTreePathNodes,
} from "../../config/sessions/transcript-tree.js";
@@ -71,7 +70,7 @@ export async function preflightGatewaySessionCompaction(
const tree = scanSessionTranscriptTree(transcriptEvents);
const branch = selectSessionTranscriptTreePathNodes(tree, tree.leafId)
.map((node) => node.entry)
.filter(isCanonicalSessionTranscriptEntry) as unknown as AgentSessionEntry[];
.filter(isIndexedSessionEntry);
const preflight = preflightManualSessionCompaction(branch, {
enabled: true,
reserveTokens: 0,
+2 -8
View File
@@ -183,14 +183,8 @@ class GatewayRestartTransaction {
acceptedConfig &&
configDebt.restartOwnedPaths.every((path) =>
isDeepStrictEqual(
getConfigValueAtPath(
configDebt.nextConfig as unknown as Record<string, unknown>,
path.split("."),
),
getConfigValueAtPath(
acceptedConfig as unknown as Record<string, unknown>,
path.split("."),
),
getConfigValueAtPath({ ...configDebt.nextConfig }, path.split(".")),
getConfigValueAtPath({ ...acceptedConfig }, path.split(".")),
),
);
if (!retainsConfigDebt) {
+2 -2
View File
@@ -211,9 +211,9 @@ export async function prepareGatewayKernelState(params: {
const channelLogs = Object.fromEntries(
listGatewayStartupChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
const channelRuntimeEnvs = Object.fromEntries(
const channelRuntimeEnvs: Partial<Record<ChannelId, RuntimeEnv>> = Object.fromEntries(
Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]),
) as unknown as Record<ChannelId, RuntimeEnv>;
);
const listStartupChannelGatewayMethods = () => {
const methods: string[] = [];
for (const plugin of listGatewayStartupChannelPlugins()) {
@@ -435,7 +435,7 @@ export async function readSessionLeafStateFromTranscriptAsync(
}
function readSessionLeafStateFromRecords(
records: readonly Record<string, unknown>[],
records: readonly { type?: unknown; id?: unknown }[],
): { entryId: string; leafId: string | null } | null {
let latestEntryId: string | undefined;
for (const record of records) {
@@ -620,7 +620,7 @@ async function captureCompactionCheckpointSnapshotAsync(params: {
if (typeof params.sessionManager?.getEntries !== "function") {
return null;
}
const entryRecords = params.sessionManager.getEntries() as unknown as Record<string, unknown>[];
const entryRecords = params.sessionManager.getEntries();
const transcriptState = readSessionLeafStateFromRecords(entryRecords);
const position = resolveCompactionCheckpointTranscriptPosition({
preferredLeafId: liveLeafId,
+1 -1
View File
@@ -239,7 +239,7 @@ export async function projectSessionsPatchEntry(params: {
};
const existing = params.existingEntry
? projectCanonicalSessionEntryShape(params.existingEntry as unknown as Record<string, unknown>)
? projectCanonicalSessionEntryShape({ ...params.existingEntry })
: undefined;
// Existing entries without session ids are placeholder aliases; assigning an id makes them real.
const next: SessionEntry = existing?.sessionId
@@ -666,9 +666,8 @@ export function createNodeWorkerTunnelManager(options: NodeWorkerTunnelManagerOp
abortController: new AbortController(),
launchTasks: new Set<Promise<unknown>>(),
};
const entry = { ...base, handle: undefined as unknown as WorkerTunnelHandle };
const created = createHandle(entry, restoredWorkspace);
entry.handle = created.handle;
const created = createHandle(base, restoredWorkspace);
const entry = Object.assign(base, { handle: created.handle });
entries.set(entry.environmentId, entry);
try {
await created.validateRestoredWorkspace();
+4 -3
View File
@@ -1,3 +1,4 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
// Gateway WebSocket log formatting.
// Redacts and compacts request/response/event metadata for console diagnostics.
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
@@ -164,7 +165,7 @@ function renderSingleErrorForLog(error: Error): string {
if (error.message) {
parts.push(error.message);
}
const codeValue = (error as unknown as { code?: unknown }).code;
const codeValue = isRecord(error) ? error.code : undefined;
const code =
typeof codeValue === "string" || typeof codeValue === "number" ? String(codeValue) : "";
if (code) {
@@ -175,12 +176,12 @@ function renderSingleErrorForLog(error: Error): string {
function renderErrorChainForLog(error: Error): string {
const segments: string[] = [renderSingleErrorForLog(error)];
let current: unknown = (error as unknown as { cause?: unknown }).cause;
let current: unknown = error.cause;
let depth = 0;
while (current !== undefined && current !== null && depth < 8) {
if (current instanceof Error) {
segments.push(renderSingleErrorForLog(current));
current = (current as unknown as { cause?: unknown }).cause;
current = current.cause;
} else {
segments.push(stringifyNonErrorCause(current));
current = undefined;
+5 -5
View File
@@ -8,7 +8,7 @@ type AgentItemEventStatus = "running" | "completed" | "failed" | "blocked";
type AgentItemEventKind = "tool" | "command" | "patch" | "search" | "analysis" | (string & {});
/** Payload for a single item shown in the agent activity stream. */
export type AgentItemEventData = {
export type AgentItemEventData = Record<string, unknown> & {
itemId: string;
phase: AgentItemEventPhase;
kind: AgentItemEventKind;
@@ -32,7 +32,7 @@ export type AgentItemEventData = {
};
/** Incremental command output payload associated with an item/tool call. */
export type AgentCommandOutputEventData = {
export type AgentCommandOutputEventData = Record<string, unknown> & {
itemId: string;
phase: "delta" | "end";
title: string;
@@ -46,7 +46,7 @@ export type AgentCommandOutputEventData = {
};
/** Patch summary payload emitted after an agent applies file changes. */
export type AgentPatchSummaryEventData = {
export type AgentPatchSummaryEventData = Record<string, unknown> & {
itemId: string;
phase: "end";
title: string;
@@ -60,7 +60,7 @@ export type AgentPatchSummaryEventData = {
type AgentActivityEventDataByStream = {
item: AgentItemEventData;
approval: AgentApprovalEventData;
approval: AgentApprovalEventData & Record<string, unknown>;
command_output: AgentCommandOutputEventData;
patch: AgentPatchSummaryEventData;
};
@@ -79,7 +79,7 @@ export function emitAgentActivityEvent(params: AgentActivityEventParams): void {
emitAgentEvent({
runId: params.runId,
stream: params.stream,
data: params.data as unknown as Record<string, unknown>,
data: params.data,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
});
}
+13 -3
View File
@@ -7,10 +7,20 @@ type ExecPolicyLayer = {
ask?: ExecAsk;
};
type RequiredExecPolicy = Required<Pick<ExecPolicyLayer, "security" | "ask">>;
export function applyExecPolicyLayer<TBase extends ExecPolicyLayer & RequiredExecPolicy>(
base: TBase,
layer?: ExecPolicyLayer,
): Omit<TBase, keyof ExecPolicyLayer> & ExecPolicyLayer & RequiredExecPolicy;
export function applyExecPolicyLayer<TBase extends ExecPolicyLayer>(
base: TBase,
layer?: ExecPolicyLayer,
): TBase & ExecPolicyLayer {
): Omit<TBase, keyof ExecPolicyLayer> & ExecPolicyLayer;
export function applyExecPolicyLayer(
base: ExecPolicyLayer,
layer?: ExecPolicyLayer,
): ExecPolicyLayer {
if (!layer) {
return base;
}
@@ -19,7 +29,7 @@ export function applyExecPolicyLayer<TBase extends ExecPolicyLayer>(
...base,
mode: layer.mode,
...resolveExecPolicyForMode(layer.mode),
} as unknown as TBase & ExecPolicyLayer;
};
}
if (layer.security !== undefined || layer.ask !== undefined) {
const { mode: _mode, ...baseWithoutMode } = base;
@@ -27,7 +37,7 @@ export function applyExecPolicyLayer<TBase extends ExecPolicyLayer>(
...baseWithoutMode,
security: layer.security ?? base.security,
ask: layer.ask ?? base.ask,
} as unknown as TBase & ExecPolicyLayer;
};
}
return base;
}
+6 -12
View File
@@ -1,6 +1,6 @@
// SSRF policy helpers validate hostnames/IP literals, build pinned DNS lookups,
// and create dispatcher policies for guarded network fetches.
import { lookup as dnsLookupCb, type LookupAddress } from "node:dns";
import { lookup as dnsLookupCb, type LookupAddress, type LookupOptions } from "node:dns";
import { lookup as dnsLookup } from "node:dns/promises";
import {
extractEmbeddedIpv4FromIpv6,
@@ -487,15 +487,6 @@ export function createPinnedLookup(params: {
throw new Error(`Pinned lookup requires at least one address for ${params.hostname}`);
}
const fallback = params.fallback ?? dnsLookupCb;
const fallbackLookup = fallback as unknown as (
hostname: string,
callback: LookupCallback,
) => void;
const fallbackWithOptions = fallback as unknown as (
hostname: string,
options: unknown,
callback: LookupCallback,
) => void;
const records = params.addresses.map((address) => ({
address,
family: address.includes(":") ? 6 : 4,
@@ -513,9 +504,12 @@ export function createPinnedLookup(params: {
const normalized = normalizeHostname(host);
if (!normalized || normalized !== normalizedHost) {
if (typeof options === "function" || options === undefined) {
return fallbackLookup(host, cb);
return fallback(host, cb);
}
return fallbackWithOptions(host, options, cb);
if (typeof options === "number") {
return fallback(host, options, cb);
}
return fallback(host, options as LookupOptions, cb);
}
const opts =
+4 -5
View File
@@ -243,7 +243,8 @@ export function normalizeSessionEntry(
entry: SessionEntryLike,
sessionKey?: string,
): SessionEntry | null {
const shaped = normalizePersistedSessionEntryShape(entry, { sessionKey });
const { room, ...entryWithoutRoom } = entry;
const shaped = normalizePersistedSessionEntryShape(entryWithoutRoom, { sessionKey });
if (!shaped) {
return null;
}
@@ -254,11 +255,9 @@ export function normalizeSessionEntry(
? normalized.updatedAt
: Date.now();
}
const rec = normalized as unknown as Record<string, unknown>;
if (typeof rec.groupChannel !== "string" && typeof rec.room === "string") {
rec.groupChannel = rec.room;
if (typeof normalized.groupChannel !== "string" && typeof room === "string") {
normalized.groupChannel = room;
}
delete rec.room;
return normalized;
}
+1 -4
View File
@@ -352,10 +352,7 @@ function logToFile(
if (level === "silent") {
return;
}
const safeLevel = level;
const method = (fileLogger as unknown as Record<string, unknown>)[safeLevel] as
| ((...args: unknown[]) => void)
| undefined;
const method = fileLogger[level];
if (typeof method !== "function") {
return;
}
+5 -1
View File
@@ -18,6 +18,10 @@ type CallPluginToolParams = {
arguments?: unknown;
};
type ToolWithBeforeToolCallHookContext = AnyAgentTool & {
[BEFORE_TOOL_CALL_HOOK_CONTEXT]?: unknown;
};
function toMcpContentBlock(block: unknown): unknown {
if (!isRecord(block)) {
return { type: "text", text: coerceChatContentText(block) };
@@ -56,7 +60,7 @@ function resolveJsonSchemaForTool(tool: AnyAgentTool): Record<string, unknown> {
}
function resolveBeforeToolCallRunId(tool: AnyAgentTool): string | undefined {
const context = (tool as unknown as Record<symbol, unknown>)[BEFORE_TOOL_CALL_HOOK_CONTEXT];
const context = (tool as ToolWithBeforeToolCallHookContext)[BEFORE_TOOL_CALL_HOOK_CONTEXT];
return isRecord(context) && typeof context.runId === "string" ? context.runId : undefined;
}
+2 -2
View File
@@ -1049,7 +1049,7 @@ export async function runCliEntry(params: {
});
const outputBase = path.join(outputDir, path.parse(mediaPath).name);
const templCtx: TemplateContext = {
const templCtx: TemplateContext & Record<string, unknown> = {
...ctx,
AttachmentPath: mediaPath,
AttachmentUrl: params.attachment.url ?? params.attachment.path ?? mediaPath,
@@ -1074,7 +1074,7 @@ export async function runCliEntry(params: {
"MediaTranscribedIndexes",
"MediaStaged",
]) {
delete (templCtx as unknown as Record<string, unknown>)[key];
delete templCtx[key];
}
const argv = [command, ...args].map((part, index) =>
index === 0 ? part : applyTemplate(part, templCtx),
+2 -2
View File
@@ -35,13 +35,13 @@ export async function saveRemoteMediaForStore(params: {
}): Promise<SavedMedia> {
const resolvePinned = params.resolvePinnedHostnameForTest;
const lookupFn: LookupFn | undefined = resolvePinned
? ((async (hostname: string) => {
? async (hostname, _options) => {
const pinned = await resolvePinned(hostname);
return pinned.addresses.map((address) => ({
address,
family: address.includes(":") ? 6 : 4,
}));
}) as unknown as LookupFn)
}
: undefined;
const { id, path, size, contentType } = await saveRemoteMedia({
url: params.source,
@@ -74,8 +74,7 @@ export function createLocalMeetingRealtimeAudioTransport(params: {
const input = splitCommand(params.inputCommand);
const output = splitCommand(params.outputCommand);
const spawnFn: MeetingRealtimeAudioSpawn =
params.spawn ??
((command, args, options) => spawn(command, args, options) as unknown as BridgeProcess);
params.spawn ?? ((command, args, options) => spawn(command, args, options));
const spawnOutputProcess = () =>
spawnFn(output.command, output.args, { stdio: ["pipe", "ignore", "pipe"] });
let outputProcess = spawnOutputProcess();
+31 -28
View File
@@ -1,3 +1,4 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveVoiceModelRefs } from "../tts/voice-models.js";
@@ -42,11 +43,8 @@ type CapabilityContractKey =
| "videoGenerationProviders"
| "musicGenerationProviders";
type ProviderFor<K extends CapabilityProviderRegistryKey> = PluginRegistry[K][number] extends {
provider: infer T;
}
? T
: never;
export type CapabilityProviderFor<K extends CapabilityProviderRegistryKey> =
PluginRegistry[K][number]["provider"];
type CapabilityPluginResolution = {
runtimePluginIds: string[];
bundledCompatPluginIds: string[];
@@ -149,24 +147,29 @@ function createCapabilityProviderLoadOptions(params: {
function findProviderById<K extends CapabilityProviderRegistryKey>(
entries: PluginRegistry[K],
providerId: string,
): ProviderFor<K> | undefined {
): CapabilityProviderFor<K> | undefined {
const normalizedProviderId = normalizeCapabilityProviderId(providerId);
if (!normalizedProviderId) {
return undefined;
}
const providerEntries = entries as unknown as Array<{
provider: ProviderFor<K> & { id?: unknown; aliases?: unknown };
}>;
for (const entry of providerEntries) {
for (const entry of entries) {
const provider: unknown = entry.provider;
if (!isRecord(provider)) {
continue;
}
if (
typeof entry.provider.id === "string" &&
normalizeCapabilityProviderId(entry.provider.id) === normalizedProviderId
typeof provider.id === "string" &&
normalizeCapabilityProviderId(provider.id) === normalizedProviderId
) {
return entry.provider;
return entry.provider as CapabilityProviderFor<K>;
}
}
for (const entry of providerEntries) {
const aliases = Array.isArray(entry.provider.aliases) ? entry.provider.aliases : [];
for (const entry of entries) {
const provider: unknown = entry.provider;
if (!isRecord(provider)) {
continue;
}
const aliases = Array.isArray(provider.aliases) ? provider.aliases : [];
if (
aliases.some(
(alias) =>
@@ -174,7 +177,7 @@ function findProviderById<K extends CapabilityProviderRegistryKey>(
normalizeCapabilityProviderId(alias) === normalizedProviderId,
)
) {
return entry.provider;
return entry.provider as CapabilityProviderFor<K>;
}
}
return undefined;
@@ -183,12 +186,12 @@ function findProviderById<K extends CapabilityProviderRegistryKey>(
function mergeCapabilityProviders<K extends CapabilityProviderRegistryKey>(
left: PluginRegistry[K],
right: PluginRegistry[K],
): ProviderFor<K>[] {
const merged = new Map<string, ProviderFor<K>>();
const unnamed: ProviderFor<K>[] = [];
): CapabilityProviderFor<K>[] {
const merged = new Map<string, CapabilityProviderFor<K>>();
const unnamed: CapabilityProviderFor<K>[] = [];
const addEntries = (entries: PluginRegistry[K]) => {
for (const entry of entries) {
const provider = entry.provider as ProviderFor<K> & { id?: string };
const provider = entry.provider as CapabilityProviderFor<K> & { id?: string };
if (!provider.id) {
unnamed.push(provider);
continue;
@@ -363,10 +366,10 @@ function filterLoadedProvidersForRequestedConfig<K extends CapabilityProviderReg
params.key !== "realtimeVoiceProviders" &&
params.key !== "mediaUnderstandingProviders"
) {
return [] as unknown as PluginRegistry[K];
return [];
}
if (params.requested.size === 0) {
return [] as unknown as PluginRegistry[K];
return [];
}
return params.entries.filter((entry) => {
const provider = entry.provider as { id?: unknown; aliases?: unknown };
@@ -495,7 +498,7 @@ export function resolvePluginCapabilityProvider<K extends CapabilityProviderRegi
key: K;
providerId: string;
cfg?: OpenClawConfig;
}): ProviderFor<K> | undefined {
}): CapabilityProviderFor<K> | undefined {
if (shouldSkipCapabilityResolution(params)) {
return undefined;
}
@@ -549,7 +552,7 @@ export function resolvePluginCapabilityProvider<K extends CapabilityProviderRegi
export function resolvePluginCapabilityProviders<K extends CapabilityProviderRegistryKey>(params: {
key: K;
cfg?: OpenClawConfig;
}): ProviderFor<K>[] {
}): CapabilityProviderFor<K>[] {
if (shouldSkipCapabilityResolution(params)) {
return [];
}
@@ -573,12 +576,12 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
: undefined;
if (activeProviders.length > 0 && params.key !== "memoryEmbeddingProviders") {
if (!missingRequestedProviders && !shouldMergeManifestProvidersWhenActive(params.key)) {
return activeProviders.map((entry) => entry.provider) as ProviderFor<K>[];
return activeProviders.map((entry) => entry.provider) as CapabilityProviderFor<K>[];
}
if (missingRequestedProviders) {
removeActiveProviderIds(missingRequestedProviders, activeProviders);
if (missingRequestedProviders.size === 0) {
return activeProviders.map((entry) => entry.provider) as ProviderFor<K>[];
return activeProviders.map((entry) => entry.provider) as CapabilityProviderFor<K>[];
}
}
}
@@ -650,7 +653,7 @@ export function prepareMediaCapabilityProviders(params: {
}) {
const providers = <K extends CapabilityProviderRegistryKey>(
key: K,
): readonly ProviderFor<K>[] | undefined => {
): readonly CapabilityProviderFor<K>[] | undefined => {
if (shouldSkipCapabilityResolution({ key, cfg: params.cfg })) {
return [];
}
@@ -687,7 +690,7 @@ export function prepareMediaCapabilityProviders(params: {
}
return Object.freeze(
availableEntries.map((entry) => entry.provider),
) as readonly ProviderFor<K>[];
) as readonly CapabilityProviderFor<K>[];
};
return Object.freeze({
mediaUnderstandingProviders: providers("mediaUnderstandingProviders"),
+9 -12
View File
@@ -100,7 +100,7 @@ function asLegacyTtsConfig(value: unknown): OpenClawConfig {
}
function asLegacyOpenClawConfig(value: Record<string, unknown>): OpenClawConfig {
return value as unknown as OpenClawConfig;
return asLegacyTtsConfig(value);
}
function mockCallAt(mock: { mock: { calls: Array<Array<unknown>> } }, index: number): unknown[] {
@@ -182,11 +182,8 @@ async function withMockedSpeechFetch(
audioLength: number,
) {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(audioLength),
}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const fetchMock = vi.fn(async () => new Response(new Uint8Array(audioLength)));
globalThis.fetch = fetchMock;
try {
await run(fetchMock);
} finally {
@@ -559,12 +556,12 @@ export function describeTtsConfigContract() {
},
{
name: "override",
cfg: {
cfg: asLegacyTtsConfig({
...baseCfg,
tts: {
edge: { outputFormat: "audio-24khz-96kbitrate-mono-mp3" },
},
} as unknown as OpenClawConfig,
}),
expected: "audio-24khz-96kbitrate-mono-mp3",
},
] as const)("$name", ({ cfg, expected, name }) => {
@@ -796,22 +793,22 @@ export function describeTtsConfigContract() {
},
{
name: "config wins over env",
cfg: {
cfg: asLegacyTtsConfig({
...baseCfg,
tts: { ...baseCfg.tts, openai: { baseUrl: "http://my-server:9000/v1" } },
} as unknown as OpenClawConfig,
}),
env: { OPENAI_TTS_BASE_URL: "http://localhost:8880/v1" },
expected: "http://my-server:9000/v1",
},
{
name: "config slash trimming",
cfg: {
cfg: asLegacyTtsConfig({
...baseCfg,
tts: {
...baseCfg.tts,
openai: { baseUrl: "http://my-server:9000/v1///" },
},
} as unknown as OpenClawConfig,
}),
env: { OPENAI_TTS_BASE_URL: undefined },
expected: "http://my-server:9000/v1",
},
@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolvePluginCapabilityProvider,
resolvePluginCapabilityProviders,
type CapabilityProviderFor,
} from "./capability-provider-runtime.js";
type EmbeddingProviderCapabilityKey = "embeddingProviders" | "memoryEmbeddingProviders";
@@ -29,16 +30,18 @@ export function resolveRuntimeEmbeddingProviderLookupIds(params: {
}
/** Lists registered and plugin-contributed embedding provider adapters for a capability key. */
export function listRuntimeEmbeddingProviderAdapters<TAdapter extends { id: string }>(params: {
key: EmbeddingProviderCapabilityKey;
export function listRuntimeEmbeddingProviderAdapters<
K extends EmbeddingProviderCapabilityKey,
>(params: {
key: K;
cfg?: OpenClawConfig;
registered: TAdapter[];
}): TAdapter[] {
registered: CapabilityProviderFor<K>[];
}): CapabilityProviderFor<K>[] {
const merged = new Map(params.registered.map((adapter) => [adapter.id, adapter]));
const capabilityAdapters = resolvePluginCapabilityProviders({
key: params.key,
cfg: params.cfg,
}) as unknown as TAdapter[];
});
for (const adapter of capabilityAdapters) {
if (!merged.has(adapter.id)) {
merged.set(adapter.id, adapter);
@@ -48,12 +51,16 @@ export function listRuntimeEmbeddingProviderAdapters<TAdapter extends { id: stri
}
/** Resolves one embedding provider adapter from registered providers before plugin capabilities. */
export function getRuntimeEmbeddingProviderAdapter<TAdapter extends { id: string }>(params: {
key: EmbeddingProviderCapabilityKey;
export function getRuntimeEmbeddingProviderAdapter<
K extends EmbeddingProviderCapabilityKey,
>(params: {
key: K;
cfg?: OpenClawConfig;
lookupIds: string[];
getRegisteredProvider: (id: string) => RegisteredAdapterEntry<TAdapter> | undefined;
}): TAdapter | undefined {
getRegisteredProvider: (
id: string,
) => RegisteredAdapterEntry<CapabilityProviderFor<K>> | undefined;
}): CapabilityProviderFor<K> | undefined {
// Resolve each exact id before trying the next configured alias. Otherwise a
// registered alias can shadow a plugin-owned adapter for the requested id.
for (const candidateId of params.lookupIds) {
@@ -65,7 +72,7 @@ export function getRuntimeEmbeddingProviderAdapter<TAdapter extends { id: string
key: params.key,
providerId: candidateId,
cfg: params.cfg,
}) as TAdapter | undefined;
});
if (provider) {
return provider;
}
+1 -6
View File
@@ -2,11 +2,6 @@ import type { PluginHookName } from "./types.js";
export class HookIsolationError extends Error {}
type WebAssemblyMemoryConstructor = {
new (...args: unknown[]): object;
prototype: object;
};
function containsSharedMemory(value: unknown, seen: Set<object>): boolean {
if (typeof SharedArrayBuffer !== "undefined" && value instanceof SharedArrayBuffer) {
return true;
@@ -22,7 +17,7 @@ function containsSharedMemory(value: unknown, seen: Set<object>): boolean {
}
const webAssemblyMemory = (
globalThis as unknown as {
WebAssembly?: { Memory?: WebAssemblyMemoryConstructor };
WebAssembly?: { Memory?: { new (...args: unknown[]): object; prototype: object } };
}
).WebAssembly?.Memory;
if (
+3 -1
View File
@@ -29,6 +29,8 @@ const MAX_PLUGIN_NEXT_TURN_INJECTION_TEXT_LENGTH = 32 * 1024;
const MAX_PLUGIN_NEXT_TURN_INJECTION_IDEMPOTENCY_KEY_LENGTH = 512;
const MAX_PLUGIN_NEXT_TURN_INJECTIONS_PER_SESSION = 32;
type MutableSessionEntry = SessionEntry & Record<string, unknown>;
function normalizeNamespace(value: string): string {
return value.trim();
}
@@ -326,7 +328,7 @@ export async function patchPluginSessionExtension(params: {
},
(entry, context) => {
params.assertCurrent?.();
const entryRecord = entry as unknown as Record<string, unknown>;
const entryRecord = entry as MutableSessionEntry;
const pluginExtensions = { ...entry.pluginExtensions };
const pluginState = { ...pluginExtensions[pluginId] };
if (params.unset === true) {
@@ -177,9 +177,9 @@ export async function importSessionCatalogHistory(params: {
continue;
}
const message = {
...(imported as unknown as Record<string, unknown>),
...imported,
idempotencyKey: `${params.catalogId}-catalog:${params.threadId}:${item.id ?? index}`,
} as unknown as AgentMessage;
};
await transcript.appendMessage({
message,
idempotencyLookup: "scan",
+2 -2
View File
@@ -61,7 +61,7 @@ async function readCapturedResponseBodyBounded(
maxBytes: number,
): Promise<CapturedResponseBodyResult> {
const clone = response.clone();
const body = (clone as unknown as { body?: ReadableStream<Uint8Array> | null }).body;
const body = clone.body;
if (!body || typeof body.getReader !== "function") {
// A real null-body Response consumes as empty. Response-like objects without
// a stream cannot be read under a byte cap, so never call arrayBuffer().
@@ -76,7 +76,7 @@ async function readCapturedResponseBodyBounded(
let stalled = false;
try {
while (true) {
let next: Awaited<ReturnType<typeof reader.read>>;
let next: Awaited<ReturnType<typeof readChunkWithIdleTimeout>>;
try {
next = await readChunkWithIdleTimeout(
reader,
@@ -40,7 +40,11 @@ export function collectAgentMemorySearchAssignments(params: {
let defaultApiKeyAssignmentCollected = false;
const collectedDefaultHeaderKeys = new Set<string>();
const collectForAgent = ({ entry: rawAgent, source }: ListedAgentEntry) => {
const rawAgentRecord = rawAgent as unknown as Record<string, unknown>;
const rawAgentValue: unknown = rawAgent;
if (!isRecord(rawAgentValue)) {
return;
}
const rawAgentRecord = rawAgentValue;
const agentMemory = isRecord(rawAgentRecord.memory) ? rawAgentRecord.memory : undefined;
const memorySearch = isRecord(agentMemory?.search) ? agentMemory.search : undefined;
const remote = isRecord(memorySearch?.remote) ? memorySearch.remote : undefined;
@@ -48,7 +52,7 @@ export function collectAgentMemorySearchAssignments(params: {
const agentPath =
source.kind === "entries" ? `agents.entries.${source.key}` : `agents.list.${source.index}`;
const active =
rawAgentRecord.enabled !== false &&
rawAgentRecord["enabled"] !== false &&
(memorySearch?.enabled ?? defaultsMemorySearch?.enabled ?? true) !== false;
const owner = {
ownerKind: "capability",
@@ -58,7 +62,7 @@ export function collectAgentMemorySearchAssignments(params: {
contract: {
defaults: defaultsMemorySearch,
override: memorySearch,
agentEnabled: rawAgentRecord.enabled,
agentEnabled: rawAgentRecord["enabled"],
},
} satisfies SecretAssignmentOwner;
@@ -78,7 +78,11 @@ export function collectAgentSandboxAssignments(params: {
for (const candidate of candidates) {
const rawAgent = candidate.entry;
const rawAgentRecord = rawAgent as unknown as Record<string, unknown>;
const rawAgentValue: unknown = rawAgent;
if (!isRecord(rawAgentValue)) {
continue;
}
const rawAgentRecord = rawAgentValue;
const agentId = normalizeAgentId(candidate.entryId);
if (seenAgentIds.has(agentId)) {
continue;
@@ -99,8 +103,8 @@ export function collectAgentSandboxAssignments(params: {
? (defaultsSandbox.scope as "agent" | "session" | "shared")
: undefined,
perSession:
typeof sandbox?.perSession === "boolean"
? sandbox.perSession
typeof sandbox?.["perSession"] === "boolean"
? sandbox["perSession"]
: typeof defaultsSandbox?.perSession === "boolean"
? defaultsSandbox.perSession
: undefined,
@@ -112,7 +116,7 @@ export function collectAgentSandboxAssignments(params: {
const owner = sandboxSecretOwner(agentId, {
defaults: defaultsSandbox,
override: sandbox,
agentEnabled: rawAgentRecord.enabled,
agentEnabled: rawAgentRecord["enabled"],
});
for (const key of SANDBOX_SSH_SECRET_KEYS) {
+1 -1
View File
@@ -797,7 +797,7 @@ export class EmbeddedTuiBackend implements TuiBackend {
ok: true as const,
path: target.storePath,
key: target.canonicalKey ?? opts.key,
entry: applied.entry as unknown as Record<string, unknown>,
entry: { ...applied.entry },
resolved: {
modelProvider: resolved.provider,
model: resolved.model,
+14 -13
View File
@@ -499,10 +499,10 @@ export function beginTuiShutdown(params: {
clearTimeoutFn?: (timer: TuiProcessExitTimer) => void;
setTimeoutFn?: TuiProcessExitTimeout;
}): TuiProcessExitTimer {
const setTimeoutFn =
params.setTimeoutFn ??
((callback, timeoutMs) => setTimeout(callback, timeoutMs) as unknown as TuiProcessExitTimer);
const hardExitTimer = setTimeoutFn(params.forceExit, params.hardExitMs);
const hardExit = params.setTimeoutFn
? { kind: "custom" as const, timer: params.setTimeoutFn(params.forceExit, params.hardExitMs) }
: { kind: "native" as const, timer: setTimeout(params.forceExit, params.hardExitMs) };
const hardExitTimer = hardExit.timer;
hardExitTimer.unref?.();
// Stop referenced animations before transport teardown can stall or redraw.
params.disposeStatus();
@@ -529,10 +529,11 @@ export function beginTuiShutdown(params: {
})
.finally(() => {
if (params.keepHardExitArmed !== true) {
const clearTimeoutFn =
params.clearTimeoutFn ??
((timer) => clearTimeout(timer as unknown as ReturnType<typeof setTimeout>));
clearTimeoutFn(hardExitTimer);
if (params.clearTimeoutFn) {
params.clearTimeoutFn(hardExitTimer);
} else if (hardExit.kind === "native") {
clearTimeout(hardExit.timer);
}
}
params.disposeStatus();
})
@@ -607,23 +608,23 @@ export function scheduleProcessExitAfterTuiReturn(
} = {},
): TuiProcessExitTimer {
const delayMs = Math.max(0, Math.floor(params.delayMs ?? TUI_PROCESS_EXIT_AFTER_RETURN_MS));
const setTimeoutFn =
params.setTimeoutFn ??
((callback, timeoutMs) => setTimeout(callback, timeoutMs) as unknown as TuiProcessExitTimer);
const exit = params.exit ?? ((code?: number) => process.exit(code));
const writeStderr =
params.writeStderr ??
((text: string) => {
process.stderr.write(text);
});
const timer = setTimeoutFn(() => {
const onTimeout = () => {
try {
writeStderr("openclaw tui forcing process exit after return\n");
} catch {
// Best effort only; forced exit must not depend on stderr.
}
exit(0);
}, delayMs);
};
const timer = params.setTimeoutFn
? params.setTimeoutFn(onTimeout, delayMs)
: setTimeout(onTimeout, delayMs);
timer.unref?.();
return timer;
}