mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
perf: reduce raw gateway config startup work
This commit is contained in:
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/performance: keep raw channel-config schema parsing from discovering bundled plugin runtime metadata, and add `pnpm gateway:watch --benchmark-no-force` for profiling startup without the default port cleanup.
|
||||
- Plugins/onboarding: let Manual setup install optional official plugins, including ClawHub-backed diagnostics with npm fallback, and expose the external Codex plugin as a selectable provider setup choice. Thanks @vincentkoc.
|
||||
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
|
||||
- Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant.
|
||||
|
||||
@@ -143,6 +143,9 @@ npx speedscope .artifacts/gateway-watch-profiles/*.cpuprofile
|
||||
```
|
||||
|
||||
Use `--benchmark-dir <path>` when you want profiles somewhere else.
|
||||
Use `--benchmark-no-force` when you want the benchmarked child to skip the
|
||||
default `--force` port cleanup and fail fast if the Gateway port is already in
|
||||
use.
|
||||
|
||||
The tmux wrapper carries common non-secret runtime selectors such as
|
||||
`OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR`,
|
||||
|
||||
@@ -53,6 +53,7 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {})
|
||||
const passthroughArgs = [];
|
||||
let benchmarkDir = null;
|
||||
let benchmarkFlagSeen = false;
|
||||
let benchmarkNoForceSeen = false;
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
@@ -61,6 +62,12 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {})
|
||||
benchmarkDir ??= DEFAULT_BENCHMARK_PROFILE_DIR;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--benchmark-no-force") {
|
||||
benchmarkFlagSeen = true;
|
||||
benchmarkNoForceSeen = true;
|
||||
benchmarkDir ??= DEFAULT_BENCHMARK_PROFILE_DIR;
|
||||
continue;
|
||||
}
|
||||
if (typeof arg === "string" && arg.startsWith("--benchmark=")) {
|
||||
benchmarkFlagSeen = true;
|
||||
benchmarkDir = arg.slice("--benchmark=".length) || DEFAULT_BENCHMARK_PROFILE_DIR;
|
||||
@@ -91,7 +98,10 @@ const resolveGatewayWatchBenchmarkArgs = ({ args = [], env = process.env } = {})
|
||||
benchmarkDir || nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || DEFAULT_BENCHMARK_PROFILE_DIR;
|
||||
}
|
||||
return {
|
||||
args: passthroughArgs,
|
||||
args: benchmarkNoForceSeen
|
||||
? passthroughArgs.filter((arg) => arg !== "--force")
|
||||
: passthroughArgs,
|
||||
benchmarkNoForce: benchmarkNoForceSeen,
|
||||
benchmarkProfileDir: nextEnv[RUN_NODE_CPU_PROF_DIR_ENV] || null,
|
||||
env: nextEnv,
|
||||
};
|
||||
@@ -238,6 +248,9 @@ export const runGatewayWatchTmuxMain = (params = {}) => {
|
||||
if (resolvedArgs.benchmarkProfileDir) {
|
||||
log(deps.stderr, `gateway:watch benchmark CPU profiles: ${resolvedArgs.benchmarkProfileDir}`);
|
||||
}
|
||||
if (resolvedArgs.benchmarkNoForce) {
|
||||
log(deps.stderr, "gateway:watch benchmark running without --force");
|
||||
}
|
||||
|
||||
if (TMUX_DISABLE_VALUES.has((deps.env.OPENCLAW_GATEWAY_WATCH_TMUX ?? "").toLowerCase())) {
|
||||
return runForegroundWatcher({
|
||||
|
||||
@@ -7,6 +7,15 @@ const { collectChannelLegacyConfigRulesMock, listPluginDoctorLegacyConfigRulesMo
|
||||
listPluginDoctorLegacyConfigRulesMock: vi.fn((): LegacyConfigRule[] => []),
|
||||
}),
|
||||
);
|
||||
const loadPluginMetadataSnapshotMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
manifestRegistry: {
|
||||
diagnostics: [],
|
||||
plugins: [],
|
||||
},
|
||||
plugins: [],
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("../channels/plugins/legacy-config.js", () => ({
|
||||
collectChannelLegacyConfigRules: collectChannelLegacyConfigRulesMock,
|
||||
@@ -16,6 +25,10 @@ vi.mock("../plugins/doctor-contract-registry.js", () => ({
|
||||
listPluginDoctorLegacyConfigRules: listPluginDoctorLegacyConfigRulesMock,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
|
||||
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
|
||||
}));
|
||||
|
||||
import { validateConfigObjectRaw } from "./validation.js";
|
||||
|
||||
describe("config validation legacy rule loading", () => {
|
||||
@@ -24,6 +37,7 @@ describe("config validation legacy rule loading", () => {
|
||||
collectChannelLegacyConfigRulesMock.mockReturnValue([]);
|
||||
listPluginDoctorLegacyConfigRulesMock.mockReset();
|
||||
listPluginDoctorLegacyConfigRulesMock.mockReturnValue([]);
|
||||
loadPluginMetadataSnapshotMock.mockClear();
|
||||
});
|
||||
|
||||
it("does not load channel or plugin doctor legacy rules for valid raw config", () => {
|
||||
@@ -43,6 +57,7 @@ describe("config validation legacy rule loading", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
expect(collectChannelLegacyConfigRulesMock).not.toHaveBeenCalled();
|
||||
expect(listPluginDoctorLegacyConfigRulesMock).not.toHaveBeenCalled();
|
||||
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not load plugin doctor legacy rules for invalid raw config", () => {
|
||||
|
||||
@@ -187,6 +187,40 @@ function collectAllowedValuesFromBundledChannelSchemaPath(
|
||||
return collectAllowedValuesFromJsonSchemaNode(targetNode);
|
||||
}
|
||||
|
||||
function collectRawBundledChannelConfigIssues(config: OpenClawConfig): ConfigValidationIssue[] {
|
||||
if (!config.channels || !isRecord(config.channels)) {
|
||||
return [];
|
||||
}
|
||||
const issues: ConfigValidationIssue[] = [];
|
||||
for (const [channelId, schema] of bundledChannelSchemaById) {
|
||||
if (!Object.prototype.hasOwnProperty.call(config.channels, channelId)) {
|
||||
continue;
|
||||
}
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: schema as Record<string, unknown>,
|
||||
cacheKey: `raw-channel:${channelId}`,
|
||||
value: config.channels[channelId],
|
||||
applyDefaults: false,
|
||||
});
|
||||
if (result.ok) {
|
||||
continue;
|
||||
}
|
||||
for (const error of result.errors) {
|
||||
const message = error.additionalProperty
|
||||
? `${error.message}: "${error.additionalProperty}"`
|
||||
: error.message;
|
||||
issues.push({
|
||||
path:
|
||||
error.path === "<root>" ? `channels.${channelId}` : `channels.${channelId}.${error.path}`,
|
||||
message: `invalid config: ${message}`,
|
||||
allowedValues: error.allowedValues,
|
||||
allowedValuesHiddenCount: error.allowedValuesHiddenCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function collectAllowedValuesFromCustomIssue(record: UnknownIssueRecord): AllowedValuesCollection {
|
||||
const message = typeof record.message === "string" ? record.message : "";
|
||||
const expectedMatch = message.match(CUSTOM_EXPECTED_ONE_OF_RE);
|
||||
@@ -631,10 +665,18 @@ export function validateConfigObjectRaw(
|
||||
issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues),
|
||||
};
|
||||
}
|
||||
const validatedConfig = validated.data as OpenClawConfig;
|
||||
const channelIssues =
|
||||
policyIssues.length > 0 ? collectRawBundledChannelConfigIssues(validatedConfig) : [];
|
||||
if (channelIssues.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, channelIssues),
|
||||
};
|
||||
}
|
||||
if (policyIssues.length > 0) {
|
||||
return { ok: false, issues: policyIssues };
|
||||
}
|
||||
const validatedConfig = validated.data as OpenClawConfig;
|
||||
const duplicates = findDuplicateAgentDirs(validatedConfig);
|
||||
if (duplicates.length > 0) {
|
||||
return {
|
||||
|
||||
@@ -1,44 +1,18 @@
|
||||
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import type { PluginManifestChannelConfig } from "../plugins/manifest.js";
|
||||
|
||||
const loadPluginManifestRegistryMock = vi.hoisted(() =>
|
||||
vi.fn<(options?: Record<string, unknown>) => PluginManifestRegistry>(() => ({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
})),
|
||||
);
|
||||
const collectBundledChannelConfigsMock = vi.hoisted(() =>
|
||||
vi.fn<(params: unknown) => Record<string, PluginManifestChannelConfig> | undefined>(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
const loadPluginMetadataSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const collectBundledChannelConfigsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
describe("ChannelsSchema bundled runtime loading", () => {
|
||||
beforeEach(() => {
|
||||
loadPluginManifestRegistryMock.mockClear();
|
||||
loadPluginManifestRegistryMock.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
loadPluginMetadataSnapshotMock.mockClear();
|
||||
collectBundledChannelConfigsMock.mockClear();
|
||||
vi.doMock("../plugins/plugin-registry.js", () => ({
|
||||
loadPluginManifestRegistryForPluginRegistry: (options?: Record<string, unknown>) =>
|
||||
loadPluginManifestRegistryMock(options),
|
||||
loadPluginRegistrySnapshotWithMetadata: () => ({
|
||||
source: "derived",
|
||||
snapshot: { plugins: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
}));
|
||||
vi.doMock("../plugins/plugin-metadata-snapshot.js", () => ({
|
||||
loadPluginMetadataSnapshot: (options?: Record<string, unknown>) => ({
|
||||
manifestRegistry: loadPluginManifestRegistryMock(options),
|
||||
}),
|
||||
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
|
||||
}));
|
||||
vi.doMock("../plugins/bundled-channel-config-metadata.js", () => ({
|
||||
collectBundledChannelConfigs: (params: unknown) => collectBundledChannelConfigsMock(params),
|
||||
collectBundledChannelConfigs: collectBundledChannelConfigsMock,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -60,32 +34,11 @@ describe("ChannelsSchema bundled runtime loading", () => {
|
||||
});
|
||||
|
||||
expect(parsed?.defaults?.groupPolicy).toBe("open");
|
||||
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bundledChannelConfigCollector: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
|
||||
expect(collectBundledChannelConfigsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads bundled channel runtime discovery only when plugin-owned channel config is present", async () => {
|
||||
loadPluginManifestRegistryMock.mockReturnValueOnce({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "discord",
|
||||
origin: "bundled",
|
||||
channels: ["discord"],
|
||||
channelConfigs: {
|
||||
discord: {
|
||||
runtime: {
|
||||
safeParse: (value: unknown) => ({ success: true, data: value }),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifestRegistry["plugins"][number],
|
||||
],
|
||||
});
|
||||
|
||||
it("does not discover bundled channel runtime metadata during raw schema parsing", async () => {
|
||||
const runtime = await importFreshModule<typeof import("./zod-schema.providers.js")>(
|
||||
import.meta.url,
|
||||
"./zod-schema.providers.js?scope=channels-plugin-owned",
|
||||
@@ -95,59 +48,7 @@ describe("ChannelsSchema bundled runtime loading", () => {
|
||||
discord: {},
|
||||
});
|
||||
|
||||
expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([
|
||||
expect.objectContaining({
|
||||
config: {},
|
||||
}),
|
||||
]);
|
||||
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
|
||||
expect(collectBundledChannelConfigsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads a single plugin-owned runtime surface when the manifest omits runtime metadata", async () => {
|
||||
collectBundledChannelConfigsMock.mockReturnValueOnce({
|
||||
discord: {
|
||||
schema: {},
|
||||
runtime: {
|
||||
safeParse: (value: unknown) => ({ success: true, data: value }),
|
||||
},
|
||||
},
|
||||
});
|
||||
loadPluginManifestRegistryMock.mockImplementationOnce(() => ({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "discord",
|
||||
origin: "bundled",
|
||||
rootDir: "/repo/extensions/discord",
|
||||
channels: ["discord"],
|
||||
channelConfigs: {},
|
||||
} as unknown as PluginManifestRegistry["plugins"][number],
|
||||
],
|
||||
}));
|
||||
|
||||
const runtime = await importFreshModule<typeof import("./zod-schema.providers.js")>(
|
||||
import.meta.url,
|
||||
"./zod-schema.providers.js?scope=channels-plugin-owned-targeted-runtime",
|
||||
);
|
||||
|
||||
runtime.ChannelsSchema.parse({
|
||||
discord: {},
|
||||
});
|
||||
|
||||
expect(loadPluginManifestRegistryMock.mock.calls).toContainEqual([
|
||||
expect.objectContaining({
|
||||
config: {},
|
||||
}),
|
||||
]);
|
||||
expect(collectBundledChannelConfigsMock).toHaveBeenCalledTimes(1);
|
||||
expect(collectBundledChannelConfigsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginDir: "/repo/extensions/discord",
|
||||
manifest: expect.objectContaining({
|
||||
id: "discord",
|
||||
channels: ["discord"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { collectBundledChannelConfigs } from "../plugins/bundled-channel-config-metadata.js";
|
||||
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import type { PluginManifest } from "../plugins/manifest.js";
|
||||
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import type { ChannelsConfig } from "./types.channels.js";
|
||||
import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js";
|
||||
import { ContextVisibilityModeSchema, GroupPolicySchema } from "./zod-schema.core.js";
|
||||
@@ -15,35 +11,6 @@ const ChannelModelByChannelSchema = z
|
||||
.record(z.string(), z.record(z.string(), z.string()))
|
||||
.optional();
|
||||
|
||||
function getDirectChannelRuntimeSchema(channelId: string, registry: PluginManifestRegistry) {
|
||||
const record = registry.plugins.find(
|
||||
(plugin) => plugin.origin === "bundled" && plugin.channels.includes(channelId),
|
||||
);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const manifestRuntime = record.channelConfigs?.[channelId]?.runtime;
|
||||
if (manifestRuntime) {
|
||||
return manifestRuntime;
|
||||
}
|
||||
return collectBundledChannelConfigs({
|
||||
pluginDir: record.rootDir,
|
||||
manifest: {
|
||||
id: record.id,
|
||||
configSchema: record.configSchema ?? {},
|
||||
channels: record.channels,
|
||||
channelConfigs: record.channelConfigs,
|
||||
} as PluginManifest,
|
||||
packageManifest: record.packageManifest,
|
||||
})?.[channelId]?.runtime;
|
||||
}
|
||||
|
||||
function hasPluginOwnedChannelConfig(
|
||||
value: ChannelsConfig,
|
||||
): value is ChannelsConfig & Record<string, unknown> {
|
||||
return Object.keys(value).some((key) => key !== "defaults" && key !== "modelByChannel");
|
||||
}
|
||||
|
||||
function addLegacyChannelAcpBindingIssues(
|
||||
value: unknown,
|
||||
ctx: z.RefinementCtx,
|
||||
@@ -76,43 +43,6 @@ function addLegacyChannelAcpBindingIssues(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBundledChannelConfigs(
|
||||
value: ChannelsConfig | undefined,
|
||||
ctx: z.RefinementCtx,
|
||||
): ChannelsConfig | undefined {
|
||||
if (!value || !hasPluginOwnedChannelConfig(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let next: ChannelsConfig | undefined;
|
||||
let registry: PluginManifestRegistry | undefined;
|
||||
for (const channelId of Object.keys(value)) {
|
||||
registry ??= loadPluginMetadataSnapshot({ config: {}, env: process.env }).manifestRegistry;
|
||||
const runtimeSchema = getDirectChannelRuntimeSchema(channelId, registry);
|
||||
if (!runtimeSchema) {
|
||||
continue;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(value, channelId)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = runtimeSchema.safeParse(value[channelId]);
|
||||
if (!parsed.success) {
|
||||
for (const issue of parsed.issues) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: issue.message ?? `Invalid channels.${channelId} config.`,
|
||||
path: [channelId, ...(Array.isArray(issue.path) ? issue.path : [])],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
next ??= { ...value };
|
||||
next[channelId] = parsed.data as ChannelsConfig[string];
|
||||
}
|
||||
|
||||
return next ?? value;
|
||||
}
|
||||
|
||||
export const ChannelsSchema: z.ZodType<ChannelsConfig | undefined> = z
|
||||
.object({
|
||||
defaults: z
|
||||
@@ -129,5 +59,4 @@ export const ChannelsSchema: z.ZodType<ChannelsConfig | undefined> = z
|
||||
.superRefine((value, ctx) => {
|
||||
addLegacyChannelAcpBindingIssues(value, ctx);
|
||||
})
|
||||
.transform((value, ctx) => normalizeBundledChannelConfigs(value as ChannelsConfig, ctx))
|
||||
.optional() as z.ZodType<ChannelsConfig | undefined>;
|
||||
|
||||
@@ -95,6 +95,35 @@ describe("gateway-watch tmux wrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("can remove --force from benchmarked watch runs", () => {
|
||||
const stdout = createOutput();
|
||||
const stderr = createOutput();
|
||||
const spawnSync = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
|
||||
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });
|
||||
|
||||
const code = runGatewayWatchTmuxMain({
|
||||
args: ["gateway", "--force", "--benchmark-no-force"],
|
||||
cwd: "/repo",
|
||||
env: { SHELL: "/bin/zsh" },
|
||||
nodePath: "/node",
|
||||
spawnSync,
|
||||
stderr: stderr.stream,
|
||||
stdout: stdout.stream,
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
const command = spawnSync.mock.calls[1]?.[1]?.[6] as string;
|
||||
expect(command).toContain("'OPENCLAW_RUN_NODE_CPU_PROF_DIR=.artifacts/gateway-watch-profiles'");
|
||||
expect(command).not.toContain("--benchmark-no-force");
|
||||
expect(command).toContain("'gateway'");
|
||||
expect(command).not.toContain("'--force'");
|
||||
expect(stderr.chunks.join("")).toContain("gateway:watch benchmark running without --force");
|
||||
});
|
||||
|
||||
it("preserves an explicit color override for the tmux child", () => {
|
||||
const command = buildGatewayWatchTmuxCommand({
|
||||
args: ["gateway", "--force"],
|
||||
|
||||
@@ -86,6 +86,7 @@ export type JsonSchemaValidationError = {
|
||||
path: string;
|
||||
message: string;
|
||||
text: string;
|
||||
additionalProperty?: string;
|
||||
allowedValues?: string[];
|
||||
allowedValuesHiddenCount?: number;
|
||||
};
|
||||
@@ -152,6 +153,16 @@ function getAjvAllowedValuesSummary(error: ErrorObject): ReturnType<typeof summa
|
||||
return summarizeAllowedValues(allowedValues);
|
||||
}
|
||||
|
||||
function resolveAdditionalProperty(error: ErrorObject): string | undefined {
|
||||
if (error.keyword !== "additionalProperties") {
|
||||
return undefined;
|
||||
}
|
||||
const additionalProperty = (error.params as { additionalProperty?: unknown }).additionalProperty;
|
||||
return typeof additionalProperty === "string" && additionalProperty.trim()
|
||||
? additionalProperty
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaValidationError[] {
|
||||
if (!errors || errors.length === 0) {
|
||||
return [{ path: "<root>", message: "invalid config", text: "<root>: invalid config" }];
|
||||
@@ -160,6 +171,7 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaVa
|
||||
const path = resolveAjvErrorPath(error);
|
||||
const baseMessage = error.message ?? "invalid";
|
||||
const allowedValuesSummary = getAjvAllowedValuesSummary(error);
|
||||
const additionalProperty = resolveAdditionalProperty(error);
|
||||
const message = allowedValuesSummary
|
||||
? appendAllowedValuesHint(baseMessage, allowedValuesSummary)
|
||||
: baseMessage;
|
||||
@@ -169,6 +181,7 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaVa
|
||||
path,
|
||||
message,
|
||||
text: `${safePath}: ${safeMessage}`,
|
||||
...(additionalProperty ? { additionalProperty } : {}),
|
||||
...(allowedValuesSummary
|
||||
? {
|
||||
allowedValues: allowedValuesSummary.values,
|
||||
|
||||
@@ -21,6 +21,25 @@ function isLegacySecretRefEnvMarker(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().startsWith(LEGACY_SECRETREF_ENV_MARKER_PREFIX);
|
||||
}
|
||||
|
||||
function containsLegacySecretRefEnvMarker(value: unknown, seen = new WeakSet<object>()): boolean {
|
||||
if (isLegacySecretRefEnvMarker(value)) {
|
||||
return true;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((entry) => containsLegacySecretRefEnvMarker(entry, seen));
|
||||
}
|
||||
return Object.values(value as Record<string, unknown>).some((entry) =>
|
||||
containsLegacySecretRefEnvMarker(entry, seen),
|
||||
);
|
||||
}
|
||||
|
||||
function toCandidate(
|
||||
target: DiscoveredConfigSecretTarget,
|
||||
defaults: NonNullable<OpenClawConfig["secrets"]>["defaults"] | undefined,
|
||||
@@ -39,6 +58,9 @@ function toCandidate(
|
||||
export function collectLegacySecretRefEnvMarkerCandidates(
|
||||
config: OpenClawConfig,
|
||||
): LegacySecretRefEnvMarkerCandidate[] {
|
||||
if (!containsLegacySecretRefEnvMarker(config)) {
|
||||
return [];
|
||||
}
|
||||
const defaults = config.secrets?.defaults;
|
||||
return discoverConfigSecretTargets(config)
|
||||
.map((target) => toCandidate(target, defaults))
|
||||
|
||||
Reference in New Issue
Block a user