mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 17:51:42 +00:00
* 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.
297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
// Pre-action policy for `plugins install`: decide whether an install may bypass invalid
|
|
// config so plugin-owned doctor/recovery code can repair broken plugin state.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
|
|
import type { Command } from "commander";
|
|
import { tryReadJsonSync } from "../infra/json-files.js";
|
|
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
|
|
import { findBundledPluginSource } from "../plugins/bundled-sources.js";
|
|
import { loadPluginManifest } from "../plugins/manifest.js";
|
|
import {
|
|
listOfficialExternalPluginCatalogEntries,
|
|
resolveOfficialExternalPluginId,
|
|
resolveOfficialExternalPluginInstall,
|
|
} from "../plugins/official-external-plugin-catalog.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { parseNpmPrefixSpec, resolveFileNpmSpecToLocalPath } from "./plugins-command-helpers.js";
|
|
|
|
type PluginInstallInvalidConfigPolicy = "deny" | "allow-plugin-recovery";
|
|
|
|
/** Parsed install request plus recovery metadata needed by CLI pre-action config policy. */
|
|
export type PluginInstallRequestContext = {
|
|
rawSpec: string;
|
|
normalizedSpec: string;
|
|
installKind?: "plugin";
|
|
resolvedPath?: string;
|
|
marketplace?: string;
|
|
bundledPluginId?: string;
|
|
allowInvalidConfigRecovery?: boolean;
|
|
};
|
|
|
|
type PluginInstallRequestResolution =
|
|
| { ok: true; request: PluginInstallRequestContext }
|
|
| { ok: false; error: string };
|
|
|
|
function isPluginInstallCommand(commandPath: string[]): boolean {
|
|
return commandPath[0] === "plugins" && commandPath[1] === "install";
|
|
}
|
|
|
|
function readBundledInstallRecoveryMetadata(rootDir: string): {
|
|
pluginId?: string;
|
|
allowInvalidConfigRecovery: boolean;
|
|
} {
|
|
const packageJsonPath = path.join(rootDir, "package.json");
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
return { allowInvalidConfigRecovery: false };
|
|
}
|
|
const manifest = loadPluginManifest(rootDir, false);
|
|
const pluginId = manifest.ok ? manifest.manifest.id : undefined;
|
|
const parsed = tryReadJsonSync<{
|
|
openclaw?: {
|
|
install?: {
|
|
allowInvalidConfigRecovery?: boolean;
|
|
};
|
|
};
|
|
}>(packageJsonPath);
|
|
return {
|
|
...(pluginId ? { pluginId } : {}),
|
|
allowInvalidConfigRecovery: parsed?.openclaw?.install?.allowInvalidConfigRecovery === true,
|
|
};
|
|
}
|
|
|
|
function resolveBundledInstallRecoveryMetadata(
|
|
request: Pick<
|
|
PluginInstallRequestContext,
|
|
"rawSpec" | "normalizedSpec" | "resolvedPath" | "marketplace"
|
|
>,
|
|
): {
|
|
pluginId?: string;
|
|
allowInvalidConfigRecovery?: boolean;
|
|
} {
|
|
if (request.marketplace) {
|
|
return {};
|
|
}
|
|
if (request.resolvedPath && fs.existsSync(path.join(request.resolvedPath, "package.json"))) {
|
|
const direct = readBundledInstallRecoveryMetadata(request.resolvedPath);
|
|
if (direct.pluginId || direct.allowInvalidConfigRecovery) {
|
|
return direct;
|
|
}
|
|
}
|
|
if (
|
|
resolveFileNpmSpecToLocalPath(request.rawSpec) !== null ||
|
|
(request.resolvedPath !== undefined && fs.existsSync(request.resolvedPath))
|
|
) {
|
|
return {};
|
|
}
|
|
const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec);
|
|
const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec);
|
|
for (const value of [
|
|
request.rawSpec.trim(),
|
|
request.normalizedSpec.trim(),
|
|
rawNpmPrefixSpec ?? "",
|
|
normalizedNpmPrefixSpec ?? "",
|
|
]) {
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
const bundled = findBundledPluginSource({
|
|
lookup: { kind: "npmSpec", value },
|
|
});
|
|
if (!bundled) {
|
|
continue;
|
|
}
|
|
const recovered = readBundledInstallRecoveryMetadata(bundled.localPath);
|
|
return {
|
|
pluginId: recovered.pluginId ?? bundled.pluginId,
|
|
allowInvalidConfigRecovery: recovered.allowInvalidConfigRecovery,
|
|
};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function resolveOfficialExternalInstallRecoveryMetadata(
|
|
request: Pick<PluginInstallRequestContext, "rawSpec" | "normalizedSpec" | "marketplace">,
|
|
): {
|
|
pluginId?: string;
|
|
allowInvalidConfigRecovery?: boolean;
|
|
} {
|
|
if (request.marketplace) {
|
|
return {};
|
|
}
|
|
if (resolveFileNpmSpecToLocalPath(request.rawSpec) !== null) {
|
|
return {};
|
|
}
|
|
if (fs.existsSync(resolveUserPath(request.rawSpec))) {
|
|
return {};
|
|
}
|
|
const rawNpmPrefixSpec = parseNpmPrefixSpec(request.rawSpec);
|
|
const normalizedNpmPrefixSpec = parseNpmPrefixSpec(request.normalizedSpec);
|
|
const values = new Set(
|
|
normalizeStringEntries([
|
|
request.rawSpec,
|
|
request.normalizedSpec,
|
|
rawNpmPrefixSpec ?? "",
|
|
normalizedNpmPrefixSpec ?? "",
|
|
parseRegistryNpmSpec(request.rawSpec)?.name ?? "",
|
|
parseRegistryNpmSpec(request.normalizedSpec)?.name ?? "",
|
|
rawNpmPrefixSpec ? parseRegistryNpmSpec(rawNpmPrefixSpec)?.name : "",
|
|
normalizedNpmPrefixSpec ? parseRegistryNpmSpec(normalizedNpmPrefixSpec)?.name : "",
|
|
]),
|
|
);
|
|
if (values.size === 0) {
|
|
return {};
|
|
}
|
|
for (const entry of listOfficialExternalPluginCatalogEntries()) {
|
|
const install = resolveOfficialExternalPluginInstall(entry);
|
|
const npmSpec = install?.npmSpec?.trim() || entry.name?.trim();
|
|
if (!npmSpec || !values.has(npmSpec)) {
|
|
continue;
|
|
}
|
|
const pluginId = resolveOfficialExternalPluginId(entry);
|
|
return {
|
|
...(pluginId ? { pluginId } : {}),
|
|
allowInvalidConfigRecovery: install?.allowInvalidConfigRecovery === true,
|
|
};
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function resolvePluginInstallArgvTokens(commandPath: string[], argv: string[]): string[] {
|
|
const args = argv.slice(2);
|
|
let cursor = 0;
|
|
for (const segment of commandPath) {
|
|
while (cursor < args.length && args[cursor] !== segment) {
|
|
cursor += 1;
|
|
}
|
|
if (cursor >= args.length) {
|
|
return [];
|
|
}
|
|
cursor += 1;
|
|
}
|
|
return args.slice(cursor);
|
|
}
|
|
|
|
function resolvePluginInstallArgvRequest(commandPath: string[], argv: string[]) {
|
|
if (!isPluginInstallCommand(commandPath)) {
|
|
return null;
|
|
}
|
|
const tokens = resolvePluginInstallArgvTokens(commandPath, argv);
|
|
let rawSpec: string | null = null;
|
|
let marketplace: string | undefined;
|
|
for (let index = 0; index < tokens.length; index += 1) {
|
|
const token = tokens.at(index);
|
|
if (token === undefined) {
|
|
break;
|
|
}
|
|
if (token.startsWith("--marketplace=")) {
|
|
marketplace = token.slice("--marketplace=".length);
|
|
continue;
|
|
}
|
|
if (token === "--marketplace") {
|
|
const value = tokens[index + 1];
|
|
if (typeof value === "string") {
|
|
marketplace = value;
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (token.startsWith("-")) {
|
|
continue;
|
|
}
|
|
rawSpec ??= token;
|
|
}
|
|
return rawSpec ? { rawSpec, marketplace } : null;
|
|
}
|
|
|
|
/** Resolve install metadata from the raw spec before Commander action handlers mutate config. */
|
|
export function resolvePluginInstallRequestContext(params: {
|
|
rawSpec: string;
|
|
marketplace?: string;
|
|
installKind?: "plugin";
|
|
}): PluginInstallRequestResolution {
|
|
if (params.marketplace) {
|
|
return {
|
|
ok: true,
|
|
request: {
|
|
rawSpec: params.rawSpec,
|
|
normalizedSpec: params.rawSpec,
|
|
installKind: "plugin",
|
|
marketplace: params.marketplace,
|
|
},
|
|
};
|
|
}
|
|
const fileSpec = resolveFileNpmSpecToLocalPath(params.rawSpec);
|
|
if (fileSpec && !fileSpec.ok) {
|
|
return {
|
|
ok: false,
|
|
error: fileSpec.error,
|
|
};
|
|
}
|
|
const normalizedSpec = fileSpec && fileSpec.ok ? fileSpec.path : params.rawSpec;
|
|
const bundledRecovered = resolveBundledInstallRecoveryMetadata({
|
|
rawSpec: params.rawSpec,
|
|
normalizedSpec,
|
|
resolvedPath: resolveUserPath(normalizedSpec),
|
|
marketplace: params.marketplace,
|
|
});
|
|
const officialRecovered = resolveOfficialExternalInstallRecoveryMetadata({
|
|
rawSpec: params.rawSpec,
|
|
normalizedSpec,
|
|
marketplace: params.marketplace,
|
|
});
|
|
const recovered =
|
|
officialRecovered.pluginId || officialRecovered.allowInvalidConfigRecovery !== undefined
|
|
? officialRecovered
|
|
: bundledRecovered;
|
|
return {
|
|
ok: true,
|
|
request: {
|
|
rawSpec: params.rawSpec,
|
|
normalizedSpec,
|
|
resolvedPath: resolveUserPath(normalizedSpec),
|
|
...(params.installKind === "plugin" || recovered.pluginId ? { installKind: "plugin" } : {}),
|
|
...(recovered.pluginId ? { bundledPluginId: recovered.pluginId } : {}),
|
|
...(recovered.allowInvalidConfigRecovery !== undefined
|
|
? { allowInvalidConfigRecovery: recovered.allowInvalidConfigRecovery }
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Recover the plugin install request from Commander state plus raw argv fallback parsing. */
|
|
export function resolvePluginInstallPreactionRequest(params: {
|
|
actionCommand: Command;
|
|
commandPath: string[];
|
|
argv: string[];
|
|
}): PluginInstallRequestContext | null {
|
|
if (!isPluginInstallCommand(params.commandPath)) {
|
|
return null;
|
|
}
|
|
const argvRequest = resolvePluginInstallArgvRequest(params.commandPath, params.argv);
|
|
const opts = params.actionCommand.opts<Record<string, unknown>>();
|
|
const marketplace =
|
|
(typeof opts.marketplace === "string" && opts.marketplace.trim()
|
|
? opts.marketplace
|
|
: argvRequest?.marketplace) || undefined;
|
|
const rawSpec =
|
|
(typeof params.actionCommand.processedArgs?.[0] === "string"
|
|
? params.actionCommand.processedArgs[0]
|
|
: argvRequest?.rawSpec) ?? null;
|
|
if (!rawSpec) {
|
|
return null;
|
|
}
|
|
const request = resolvePluginInstallRequestContext({ rawSpec, marketplace });
|
|
return request.ok ? request.request : null;
|
|
}
|
|
|
|
/** Decide whether invalid config should block a command before plugin recovery can run. */
|
|
export function resolvePluginInstallInvalidConfigPolicy(
|
|
request: PluginInstallRequestContext | null,
|
|
): PluginInstallInvalidConfigPolicy {
|
|
if (!request) {
|
|
return "deny";
|
|
}
|
|
return request.allowInvalidConfigRecovery === true ? "allow-plugin-recovery" : "deny";
|
|
}
|