diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d5e6a819b..0686b393ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/help/debugging.md b/docs/help/debugging.md index 81a990068d5..441f51469a7 100644 --- a/docs/help/debugging.md +++ b/docs/help/debugging.md @@ -143,6 +143,9 @@ npx speedscope .artifacts/gateway-watch-profiles/*.cpuprofile ``` Use `--benchmark-dir ` 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`, diff --git a/scripts/gateway-watch-tmux.mjs b/scripts/gateway-watch-tmux.mjs index b9de833975b..76a44a1ad53 100644 --- a/scripts/gateway-watch-tmux.mjs +++ b/scripts/gateway-watch-tmux.mjs @@ -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({ diff --git a/src/config/validation.legacy-rules-fast-path.test.ts b/src/config/validation.legacy-rules-fast-path.test.ts index 7e2f616d955..220f3b7ba96 100644 --- a/src/config/validation.legacy-rules-fast-path.test.ts +++ b/src/config/validation.legacy-rules-fast-path.test.ts @@ -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", () => { diff --git a/src/config/validation.ts b/src/config/validation.ts index fcaa78ba2f5..b626d68e614 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -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, + 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 === "" ? `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 { diff --git a/src/config/zod-schema.providers.lazy-runtime.test.ts b/src/config/zod-schema.providers.lazy-runtime.test.ts index 4a51db22f24..ee600bc2b00 100644 --- a/src/config/zod-schema.providers.lazy-runtime.test.ts +++ b/src/config/zod-schema.providers.lazy-runtime.test.ts @@ -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) => PluginManifestRegistry>(() => ({ - plugins: [], - diagnostics: [], - })), -); -const collectBundledChannelConfigsMock = vi.hoisted(() => - vi.fn<(params: unknown) => Record | 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) => - loadPluginManifestRegistryMock(options), - loadPluginRegistrySnapshotWithMetadata: () => ({ - source: "derived", - snapshot: { plugins: [] }, - diagnostics: [], - }), - })); vi.doMock("../plugins/plugin-metadata-snapshot.js", () => ({ - loadPluginMetadataSnapshot: (options?: Record) => ({ - 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( 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( - 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"], - }), - }), - ); - }); }); diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index 70dd07350a5..6bd3f4c8019 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -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 { - 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 = z .object({ defaults: z @@ -129,5 +59,4 @@ export const ChannelsSchema: z.ZodType = z .superRefine((value, ctx) => { addLegacyChannelAcpBindingIssues(value, ctx); }) - .transform((value, ctx) => normalizeBundledChannelConfigs(value as ChannelsConfig, ctx)) .optional() as z.ZodType; diff --git a/src/infra/gateway-watch-tmux.test.ts b/src/infra/gateway-watch-tmux.test.ts index 4e95423bf63..f96304e32de 100644 --- a/src/infra/gateway-watch-tmux.test.ts +++ b/src/infra/gateway-watch-tmux.test.ts @@ -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"], diff --git a/src/plugins/schema-validator.ts b/src/plugins/schema-validator.ts index d3b1a94e64b..11fc17f9b80 100644 --- a/src/plugins/schema-validator.ts +++ b/src/plugins/schema-validator.ts @@ -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", message: "invalid config", text: ": 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, diff --git a/src/secrets/legacy-secretref-env-marker.ts b/src/secrets/legacy-secretref-env-marker.ts index a851eeb24a1..2c56e888222 100644 --- a/src/secrets/legacy-secretref-env-marker.ts +++ b/src/secrets/legacy-secretref-env-marker.ts @@ -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()): 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).some((entry) => + containsLegacySecretRefEnvMarker(entry, seen), + ); +} + function toCandidate( target: DiscoveredConfigSecretTarget, defaults: NonNullable["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))