Skip to content

Commit 6b95f98

Browse files
authored
fix(core): make indexed access explicit across remaining src (NUIA phase 3b) (#104773)
* fix(core): make indexed access explicit in auto-reply, infra, and config Part 1/3 of the src NUIA phase-3b burn-down (#104600): iteration and destructuring over index reads, boundary guards on parsed input, and named invariants. Config path walkers bind the path head once; SQLite migration key handling is hoisted without query-shape changes. * fix(core): make indexed access explicit in cli, gateway, commands, security, shared Part 2/3: argv/token selection restructured, gateway event/attachment invariants named, security parsers stay fail-closed (invariant violations throw), edit-distance matrices access checked entries. * fix(core): make indexed access explicit across remaining src surfaces Part 3/3: channels, plugins, process, cron, plugin-sdk, media, logging, tui, hooks, daemon, and small directories. Latent bug fixed: a tailnet resolver could leak undefined through a string|null contract and now fails with a descriptive local error. * fix(core): keep optional boundaries optional after per-commit review Review findings: expectDefined misused where absence is a legitimate state. CLI --profile/route-args missing next tokens take their existing miss paths; help normalization compares --help against the last positional again; first-time plugin install spreads absent cfg.plugins; denylist scan iterates manifest dependency entries instead of throwing on omitted sections; tailnet resolver returns a guaranteed string at the source instead of a caller-side undefined throw. * refactor(core): closed-key provider labels and honest optional passthroughs PROVIDER_LABELS becomes a satisfies-typed closed record (static reads provably defined; dynamic lookups go through providerUsageLabel with honest string|undefined). Status-scan overview passes its optional params through unchanged instead of asserting them. * fix(channels): make getChatChannelMeta honestly optional The original signature claimed ChatChannelMeta while leaking undefined on bundled channel id metadata drift; three of four callers already handled absence. The return type now says so, and the one assuming caller falls back to the raw channel label. * fix(core): index-safety for post-rebase main drift Covers the sqlite-sessions flip and auth-source-plan code that landed mid-phase, plus the channel-validation test consuming the now honestly optional getChatChannelMeta. * refactor(channels): split chat-meta accessors along the SDK contract getChatChannelMeta keeps its shipped plugin-SDK signature (defined for bundled ids, fail-loud on impossible misses); new findChatChannelMeta carries the drift-tolerant optional contract for core auto-enable and formatting paths. * fix(qa-channel): own channel metadata instead of a guaranteed-undefined catalog lookup qa-channel spread getChatChannelMeta over an id that is never in the bundled catalog, shipping an empty setup meta by accident; the fail-loud SDK accessor exposed it. The channel now declares its metadata once. * fix(gateway): heartbeat projection lookahead is optional at the transcript tail expectDefined wrapped messages[i + 1] whose absence on the final message is the normal case; the adjacent ternary already handled it. Restores the plain optional read with an explicit guard in the pair condition. * fix(plugin-sdk): channel plugin factory tolerates non-bundled channel ids again createChannelPluginBase spreads bundled catalog meta for ANY channel id, where absence is the normal case for external plugins; the resolver is honestly optional again while the exported bundled-id accessor keeps the fail-loud contract. * fix(core): spreads of optional config sections stay optional Fresh-setup and first-install paths (crestodian setup inference, hook installs, agent config base, target agent models) legitimately lack the section being rebuilt; spreading undefined is the shipped {} semantics. Removes the remaining gratuitous assertion wraps found by tree audit.
1 parent 6bc5fc2 commit 6b95f98

239 files changed

Lines changed: 1314 additions & 583 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

extensions/qa-channel/src/channel-base.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// Qa Channel plugin module implements channel base behavior.
2-
import { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common";
32
import {
43
listQaChannelAccountIds,
54
resolveDefaultQaChannelAccountId,
@@ -13,15 +12,16 @@ import type { CoreConfig } from "./types.js";
1312

1413
export const QA_CHANNEL_ID = "qa-channel" as const;
1514

16-
export const qaChannelSetupMeta = { ...getChatChannelMeta(QA_CHANNEL_ID) };
15+
// qa-channel is synthetic and never in the bundled chat-meta catalog; it owns
16+
// its metadata instead of spreading a lookup that could only ever be undefined.
1717
export const qaChannelRuntimeMeta = {
18-
...qaChannelSetupMeta,
1918
id: QA_CHANNEL_ID,
2019
label: "QA Channel",
2120
selectionLabel: "QA Channel",
2221
docsPath: "/channels/qa-channel",
2322
blurb: "Synthetic QA channel for OpenClaw QA runs.",
2423
};
24+
export const qaChannelSetupMeta = qaChannelRuntimeMeta;
2525

2626
type QaChannelPluginBase = Pick<
2727
ChannelPlugin<ResolvedQaChannelAccount>,

src/acp/control-plane/manager.turn-runner.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** Runs ACP turns, failover, timeout cleanup, and detached-task progress mirroring. */
22
import type { AcpRuntime, AcpRuntimeHandle } from "@openclaw/acp-core/runtime/types";
3+
import { expectDefined } from "@openclaw/normalization-core";
34
import { logVerbose } from "../../globals.js";
45
import {
56
recordSessionHumanDirectMessage,
@@ -165,16 +166,18 @@ export async function runManagerTurn(params: {
165166
}
166167

167168
try {
168-
for (let backendIdx = 0; backendIdx < candidateBackends.length; backendIdx += 1) {
169-
const currentBackend = candidateBackends[backendIdx];
169+
for (const [backendIdx, currentBackend] of candidateBackends.entries()) {
170170
if (backendIdx > 0) {
171171
await params.runtimeHandles.close({
172172
sessionKey,
173173
reason: "backend-failover",
174174
});
175175
logVerbose(
176176
`acp-manager: switching backend for ${sessionKey} from ${describeBackendCandidate(
177-
candidateBackends[backendIdx - 1],
177+
expectDefined(
178+
candidateBackends[backendIdx - 1],
179+
"candidate backends entry at backend idx 1",
180+
),
178181
)} to ${describeBackendCandidate(currentBackend)}`,
179182
);
180183
}

src/acp/translator.presentation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ function formatThinkingLevelName(level: string): string {
110110
case "adaptive":
111111
return "Adaptive";
112112
default:
113-
return level.length > 0 ? `${level[0].toUpperCase()}${level.slice(1)}` : "Unknown";
113+
return level.length > 0 ? `${level.charAt(0).toUpperCase()}${level.slice(1)}` : "Unknown";
114114
}
115115
}
116116

@@ -126,7 +126,7 @@ function formatConfigValueName(value: string): string {
126126
case "xhigh":
127127
return "Extra High";
128128
default:
129-
return value.length > 0 ? `${value[0].toUpperCase()}${value.slice(1)}` : "Unknown";
129+
return value.length > 0 ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : "Unknown";
130130
}
131131
}
132132

src/agents/model-catalog.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,11 @@ function mergeCatalogRouteVariants(
324324
collector.indexByKey.set(key, collector.entries.length - 1);
325325
continue;
326326
}
327-
collector.entries[existingIndex] = overlayCatalogMetadata(
328-
collector.entries[existingIndex],
329-
entry,
330-
);
327+
const existingEntry = collector.entries[existingIndex];
328+
if (existingEntry === undefined) {
329+
continue;
330+
}
331+
collector.entries[existingIndex] = overlayCatalogMetadata(existingEntry, entry);
331332
}
332333
}
333334

src/agents/provider-model-auth-source-plan.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,21 @@ export function buildProviderModelAuthSourcePlan(params: {
125125
} else {
126126
const available = ordered.filter((profile) => profile.readiness !== "unavailable");
127127
if (available.length === 0) {
128-
profiles = { kind: "all-unavailable", explicitOrder, first: ordered[0] };
128+
const [firstOrdered] = ordered;
129+
profiles = firstOrdered
130+
? { kind: "all-unavailable", explicitOrder, first: firstOrdered }
131+
: { kind: "empty", explicitOrder };
129132
} else {
130133
const outsideCooldown = available.filter((profile) => profile.cooldown === "clear");
131134
if (outsideCooldown.length > 0) {
132135
profiles = { kind: "usable", explicitOrder, profiles: outsideCooldown };
133136
} else if (params.allowCooldown) {
134137
profiles = { kind: "usable", explicitOrder, profiles: available.slice(0, 1) };
135138
} else {
136-
profiles = { kind: "all-cooldown", explicitOrder, first: available[0] };
139+
const [firstAvailable] = available;
140+
profiles = firstAvailable
141+
? { kind: "all-cooldown", explicitOrder, first: firstAvailable }
142+
: { kind: "empty", explicitOrder };
137143
}
138144
}
139145
}

src/auto-reply/chunk.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ export function chunkMarkdownText(text: string, limit: number): string[] {
477477
}
478478

479479
let rawChunk = `${reopenPrefix}${rawContent}`;
480-
const brokeOnSeparator = breakIdx < text.length && /\s/.test(text[breakIdx]);
480+
const brokeOnSeparator = breakIdx < text.length && /\s/.test(text.charAt(breakIdx));
481481
let nextStart = Math.min(text.length, breakIdx + (brokeOnSeparator ? 1 : 0));
482482

483483
if (fenceToSplit) {
@@ -536,7 +536,7 @@ function scanParenAwareBreakpoints(
536536
if (!isAllowed(i)) {
537537
continue;
538538
}
539-
const char = text[i];
539+
const char = text.charAt(i);
540540
if (char === "(") {
541541
depth += 1;
542542
continue;

src/auto-reply/command-auth.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { expectDefined } from "@openclaw/normalization-core";
12
/** Command authorization helpers for owner and allowlist checks. */
23
import {
34
normalizeOptionalLowercaseString,
@@ -97,8 +98,8 @@ function resolveProviderFromContext(
9798
const inferred = inferredProviders.candidates;
9899
if (inferred.length === 1) {
99100
return {
100-
providerId: inferred[0].providerId,
101-
hadResolutionError: inferred[0].hadResolutionError,
101+
providerId: expectDefined(inferred[0], "inferred entry at 0").providerId,
102+
hadResolutionError: expectDefined(inferred[0], "inferred entry at 0").hadResolutionError,
102103
};
103104
}
104105
return {

src/auto-reply/commands-registry-normalize.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { expectDefined } from "@openclaw/normalization-core";
12
/** Normalizes slash-command text aliases and builds command detection caches. */
23
import {
34
normalizeLowercaseStringOrEmpty,
@@ -80,7 +81,7 @@ export function normalizeCommandBody(raw: string, options?: CommandNormalizeOpti
8081
const normalized = colonMatch
8182
? (() => {
8283
const [, command, rest] = colonMatch;
83-
const normalizedRest = rest.trimStart();
84+
const normalizedRest = expectDefined(rest, "commands registry normalize rest").trimStart();
8485
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
8586
})()
8687
: singleLine;

src/auto-reply/commands-registry.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/** Command-registry facade for native specs, text aliases, argument parsing, and menus. */
2+
import { expectDefined } from "@openclaw/normalization-core";
23
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
34
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
45
import {
@@ -205,7 +206,7 @@ function parsePositionalArgs(definitions: CommandArgDefinition[], raw: string):
205206
values[definition.name] = tokens.slice(index).join(" ");
206207
break;
207208
}
208-
values[definition.name] = tokens[index];
209+
values[definition.name] = expectDefined(tokens[index], "command argument token");
209210
index += 1;
210211
}
211212
return values;

src/auto-reply/heartbeat-filter.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { expectDefined } from "@openclaw/normalization-core";
12
// Transcript filter for removing heartbeat-only prompt/ack artifacts.
23
import { isRecord } from "@openclaw/normalization-core/record-coerce";
34
import { normalizeOptionalString as readString } from "@openclaw/normalization-core/string-coerce";
@@ -348,7 +349,11 @@ function advancePastAdjacentToolResults(
348349
startIndex: number,
349350
): number {
350351
let index = startIndex;
351-
while (index < messages.length && isToolResultMessage(messages[index])) {
352+
while (index < messages.length) {
353+
const message = messages.at(index);
354+
if (!message || !isToolResultMessage(message)) {
355+
break;
356+
}
352357
index++;
353358
}
354359
return index;
@@ -362,17 +367,19 @@ function hasCompletedVisibleHeartbeatResponseToolCall(
362367
messages: HeartbeatTranscriptMessage[],
363368
index: number,
364369
): boolean {
365-
const visibleCalls = collectVisibleHeartbeatResponseToolCalls(messages[index]);
370+
const message = messages.at(index);
371+
if (!message) {
372+
return false;
373+
}
374+
const visibleCalls = collectVisibleHeartbeatResponseToolCalls(message);
366375
if (visibleCalls.length === 0) {
367376
return false;
368377
}
369378
const callIds = new Set(visibleCalls.flatMap((call) => collectToolCallIds(call)));
370-
for (
371-
let resultIndex = index + 1;
372-
resultIndex < messages.length && isToolResultCompletionCandidate(messages[resultIndex]);
373-
resultIndex++
374-
) {
375-
const result = messages[resultIndex];
379+
for (const result of messages.slice(index + 1)) {
380+
if (!isToolResultCompletionCandidate(result)) {
381+
break;
382+
}
376383
if (!hasSuccessfulToolResultMessage(result)) {
377384
continue;
378385
}
@@ -399,7 +406,10 @@ function resolveHeartbeatArtifactSpanEnd(
399406
let sawNonTerminalAssistantOutput = false;
400407

401408
while (index < messages.length) {
402-
const message = messages[index];
409+
const message = messages.at(index);
410+
if (!message) {
411+
break;
412+
}
403413
if (isRealNonHeartbeatUserMessage(message, heartbeatPrompt)) {
404414
break;
405415
}
@@ -458,15 +468,17 @@ export function filterHeartbeatTranscriptArtifacts<T extends { role: string; con
458468
const result: T[] = [];
459469
let i = 0;
460470
while (i < messages.length) {
461-
if (!isHeartbeatUserMessage(messages[i], heartbeatPrompt)) {
462-
result.push(messages[i]);
471+
if (
472+
!isHeartbeatUserMessage(expectDefined(messages[i], "messages entry at i"), heartbeatPrompt)
473+
) {
474+
result.push(expectDefined(messages[i], "messages entry at i"));
463475
i++;
464476
continue;
465477
}
466478

467479
const next = resolveHeartbeatArtifactSpanEnd(messages, i, ackMaxChars, heartbeatPrompt);
468480
if (next === undefined) {
469-
result.push(messages[i]);
481+
result.push(expectDefined(messages[i], "messages entry at i"));
470482
i++;
471483
continue;
472484
}

0 commit comments

Comments
 (0)