From 6bd5735519b435d9efc6278c97ef51b718207b1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 17:14:46 +0000 Subject: [PATCH] refactor: split doctor config analysis helpers --- src/commands/doctor-config-analysis.test.ts | 34 ++++ src/commands/doctor-config-analysis.ts | 152 ++++++++++++++++++ src/commands/doctor-config-flow.ts | 165 ++------------------ 3 files changed, 196 insertions(+), 155 deletions(-) create mode 100644 src/commands/doctor-config-analysis.test.ts create mode 100644 src/commands/doctor-config-analysis.ts diff --git a/src/commands/doctor-config-analysis.test.ts b/src/commands/doctor-config-analysis.test.ts new file mode 100644 index 000000000..f9f2dafa6 --- /dev/null +++ b/src/commands/doctor-config-analysis.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + formatConfigPath, + resolveConfigPathTarget, + stripUnknownConfigKeys, +} from "./doctor-config-analysis.js"; + +describe("doctor config analysis helpers", () => { + it("formats config paths predictably", () => { + expect(formatConfigPath([])).toBe(""); + expect(formatConfigPath(["channels", "slack", "accounts", 0, "token"])).toBe( + "channels.slack.accounts[0].token", + ); + }); + + it("resolves nested config targets without throwing", () => { + const target = resolveConfigPathTarget( + { channels: { slack: { accounts: [{ token: "x" }] } } }, + ["channels", "slack", "accounts", 0], + ); + expect(target).toEqual({ token: "x" }); + expect(resolveConfigPathTarget({ channels: null }, ["channels", "slack"])).toBeNull(); + }); + + it("strips unknown config keys while keeping known values", () => { + const result = stripUnknownConfigKeys({ + hooks: {}, + unexpected: true, + } as never); + expect(result.removed).toContain("unexpected"); + expect((result.config as Record).unexpected).toBeUndefined(); + expect((result.config as Record).hooks).toEqual({}); + }); +}); diff --git a/src/commands/doctor-config-analysis.ts b/src/commands/doctor-config-analysis.ts new file mode 100644 index 000000000..dea3fa1b3 --- /dev/null +++ b/src/commands/doctor-config-analysis.ts @@ -0,0 +1,152 @@ +import path from "node:path"; +import type { ZodIssue } from "zod"; +import type { OpenClawConfig } from "../config/config.js"; +import { CONFIG_PATH } from "../config/config.js"; +import { OpenClawSchema } from "../config/zod-schema.js"; +import { note } from "../terminal/note.js"; +import { isRecord } from "../utils.js"; + +type UnrecognizedKeysIssue = ZodIssue & { + code: "unrecognized_keys"; + keys: PropertyKey[]; +}; + +function normalizeIssuePath(path: PropertyKey[]): Array { + return path.filter((part): part is string | number => typeof part !== "symbol"); +} + +function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue { + return issue.code === "unrecognized_keys"; +} + +export function formatConfigPath(parts: Array): string { + if (parts.length === 0) { + return ""; + } + let out = ""; + for (const part of parts) { + if (typeof part === "number") { + out += `[${part}]`; + continue; + } + out = out ? `${out}.${part}` : part; + } + return out || ""; +} + +export function resolveConfigPathTarget(root: unknown, path: Array): unknown { + let current: unknown = root; + for (const part of path) { + if (typeof part === "number") { + if (!Array.isArray(current)) { + return null; + } + if (part < 0 || part >= current.length) { + return null; + } + current = current[part]; + continue; + } + if (!current || typeof current !== "object" || Array.isArray(current)) { + return null; + } + const record = current as Record; + if (!(part in record)) { + return null; + } + current = record[part]; + } + return current; +} + +export function stripUnknownConfigKeys(config: OpenClawConfig): { + config: OpenClawConfig; + removed: string[]; +} { + const parsed = OpenClawSchema.safeParse(config); + if (parsed.success) { + return { config, removed: [] }; + } + + const next = structuredClone(config); + const removed: string[] = []; + for (const issue of parsed.error.issues) { + if (!isUnrecognizedKeysIssue(issue)) { + continue; + } + const issuePath = normalizeIssuePath(issue.path); + const target = resolveConfigPathTarget(next, issuePath); + if (!target || typeof target !== "object" || Array.isArray(target)) { + continue; + } + const record = target as Record; + for (const key of issue.keys) { + if (typeof key !== "string" || !(key in record)) { + continue; + } + delete record[key]; + removed.push(formatConfigPath([...issuePath, key])); + } + } + + return { config: next, removed }; +} + +export function noteOpencodeProviderOverrides(cfg: OpenClawConfig): void { + const providers = cfg.models?.providers; + if (!providers) { + return; + } + + const overrides: string[] = []; + if (providers.opencode) { + overrides.push("opencode"); + } + if (providers["opencode-zen"]) { + overrides.push("opencode-zen"); + } + if (overrides.length === 0) { + return; + } + + const lines = overrides.flatMap((id) => { + const providerEntry = providers[id]; + const api = + isRecord(providerEntry) && typeof providerEntry.api === "string" + ? providerEntry.api + : undefined; + return [ + `- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`, + api ? `- models.providers.${id}.api=${api}` : null, + ].filter((line): line is string => Boolean(line)); + }); + + lines.push( + "- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).", + ); + note(lines.join("\n"), "OpenCode Zen"); +} + +export function noteIncludeConfinementWarning(snapshot: { + path?: string | null; + issues?: Array<{ message: string }>; +}): void { + const issues = snapshot.issues ?? []; + const includeIssue = issues.find( + (issue) => + issue.message.includes("Include path escapes config directory") || + issue.message.includes("Include path resolves outside config directory"), + ); + if (!includeIssue) { + return; + } + const configRoot = path.dirname(snapshot.path ?? CONFIG_PATH); + note( + [ + `- $include paths must stay under: ${configRoot}`, + '- Move shared include files under that directory and update to relative paths like "./shared/common.json".', + `- Error: ${includeIssue.message}`, + ].join("\n"), + "Doctor warnings", + ); +} diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 289b6b047..ff97c001f 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,6 +1,5 @@ import fs from "node:fs/promises"; import path from "node:path"; -import type { ZodIssue } from "zod"; import { normalizeChatChannelId } from "../channels/registry.js"; import { isNumericTelegramUserId, @@ -17,7 +16,6 @@ import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous- import { formatConfigIssueLines } from "../config/issue-format.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { parseToolsBySenderTypedKey } from "../config/types.tools.js"; -import { OpenClawSchema } from "../config/zod-schema.js"; import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js"; import { listInterpreterLikeSafeBins, @@ -50,161 +48,18 @@ import { import { inspectTelegramAccount } from "../telegram/account-inspect.js"; import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js"; import { note } from "../terminal/note.js"; -import { isRecord, resolveHomeDir } from "../utils.js"; +import { resolveHomeDir } from "../utils.js"; +import { + formatConfigPath, + noteIncludeConfinementWarning, + noteOpencodeProviderOverrides, + resolveConfigPathTarget, + stripUnknownConfigKeys, +} from "./doctor-config-analysis.js"; import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js"; import type { DoctorOptions } from "./doctor-prompter.js"; import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js"; -type UnrecognizedKeysIssue = ZodIssue & { - code: "unrecognized_keys"; - keys: PropertyKey[]; -}; - -function normalizeIssuePath(path: PropertyKey[]): Array { - return path.filter((part): part is string | number => typeof part !== "symbol"); -} - -function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue { - return issue.code === "unrecognized_keys"; -} - -function formatPath(parts: Array): string { - if (parts.length === 0) { - return ""; - } - let out = ""; - for (const part of parts) { - if (typeof part === "number") { - out += `[${part}]`; - continue; - } - out = out ? `${out}.${part}` : part; - } - return out || ""; -} - -function resolvePathTarget(root: unknown, path: Array): unknown { - let current: unknown = root; - for (const part of path) { - if (typeof part === "number") { - if (!Array.isArray(current)) { - return null; - } - if (part < 0 || part >= current.length) { - return null; - } - current = current[part]; - continue; - } - if (!current || typeof current !== "object" || Array.isArray(current)) { - return null; - } - const record = current as Record; - if (!(part in record)) { - return null; - } - current = record[part]; - } - return current; -} - -function stripUnknownConfigKeys(config: OpenClawConfig): { - config: OpenClawConfig; - removed: string[]; -} { - const parsed = OpenClawSchema.safeParse(config); - if (parsed.success) { - return { config, removed: [] }; - } - - const next = structuredClone(config); - const removed: string[] = []; - for (const issue of parsed.error.issues) { - if (!isUnrecognizedKeysIssue(issue)) { - continue; - } - const path = normalizeIssuePath(issue.path); - const target = resolvePathTarget(next, path); - if (!target || typeof target !== "object" || Array.isArray(target)) { - continue; - } - const record = target as Record; - for (const key of issue.keys) { - if (typeof key !== "string") { - continue; - } - if (!(key in record)) { - continue; - } - delete record[key]; - removed.push(formatPath([...path, key])); - } - } - - return { config: next, removed }; -} - -function noteOpencodeProviderOverrides(cfg: OpenClawConfig) { - const providers = cfg.models?.providers; - if (!providers) { - return; - } - - // 2026-01-10: warn when OpenCode Zen overrides mask built-in routing/costs (8a194b4abc360c6098f157956bb9322576b44d51, 2d105d16f8a099276114173836d46b46cdfbdbae). - const overrides: string[] = []; - if (providers.opencode) { - overrides.push("opencode"); - } - if (providers["opencode-zen"]) { - overrides.push("opencode-zen"); - } - if (overrides.length === 0) { - return; - } - - const lines = overrides.flatMap((id) => { - const providerEntry = providers[id]; - const api = - isRecord(providerEntry) && typeof providerEntry.api === "string" - ? providerEntry.api - : undefined; - return [ - `- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`, - api ? `- models.providers.${id}.api=${api}` : null, - ].filter((line): line is string => Boolean(line)); - }); - - lines.push( - "- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).", - ); - - note(lines.join("\n"), "OpenCode Zen"); -} - -function noteIncludeConfinementWarning(snapshot: { - path?: string | null; - issues?: Array<{ message: string }>; -}): void { - const issues = snapshot.issues ?? []; - const includeIssue = issues.find( - (issue) => - issue.message.includes("Include path escapes config directory") || - issue.message.includes("Include path resolves outside config directory"), - ); - if (!includeIssue) { - return; - } - const configRoot = path.dirname(snapshot.path ?? CONFIG_PATH); - note( - [ - `- $include paths must stay under: ${configRoot}`, - '- Move shared include files under that directory and update to relative paths like "./shared/common.json".', - `- Error: ${includeIssue.message}`, - ].join("\n"), - "Doctor warnings", - ); -} - type TelegramAllowFromUsernameHit = { path: string; entry: string }; type TelegramAllowFromListRef = { @@ -1659,7 +1514,7 @@ function collectLegacyToolsBySenderKeyHits( const toolsBySender = asObjectRecord(record.toolsBySender); if (toolsBySender) { const path = [...pathParts, "toolsBySender"]; - const pathLabel = formatPath(path); + const pathLabel = formatConfigPath(path); for (const rawKey of Object.keys(toolsBySender)) { const trimmed = rawKey.trim(); if (!trimmed || trimmed === "*" || parseToolsBySenderTypedKey(trimmed)) { @@ -1702,7 +1557,7 @@ function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): { let changed = false; for (const hit of hits) { - const toolsBySender = asObjectRecord(resolvePathTarget(next, hit.toolsBySenderPath)); + const toolsBySender = asObjectRecord(resolveConfigPathTarget(next, hit.toolsBySenderPath)); if (!toolsBySender || !(hit.key in toolsBySender)) { continue; }