11import fs from "node:fs/promises" ;
22import path from "node:path" ;
3- import type { ZodIssue } from "zod" ;
43import { normalizeChatChannelId } from "../channels/registry.js" ;
54import {
65 isNumericTelegramUserId ,
@@ -17,7 +16,6 @@ import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-
1716import { formatConfigIssueLines } from "../config/issue-format.js" ;
1817import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js" ;
1918import { parseToolsBySenderTypedKey } from "../config/types.tools.js" ;
20- import { OpenClawSchema } from "../config/zod-schema.js" ;
2119import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js" ;
2220import {
2321 listInterpreterLikeSafeBins ,
@@ -50,161 +48,18 @@ import {
5048import { inspectTelegramAccount } from "../telegram/account-inspect.js" ;
5149import { listTelegramAccountIds , resolveTelegramAccount } from "../telegram/accounts.js" ;
5250import { note } from "../terminal/note.js" ;
53- import { isRecord , resolveHomeDir } from "../utils.js" ;
51+ import { resolveHomeDir } from "../utils.js" ;
52+ import {
53+ formatConfigPath ,
54+ noteIncludeConfinementWarning ,
55+ noteOpencodeProviderOverrides ,
56+ resolveConfigPathTarget ,
57+ stripUnknownConfigKeys ,
58+ } from "./doctor-config-analysis.js" ;
5459import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js" ;
5560import type { DoctorOptions } from "./doctor-prompter.js" ;
5661import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js" ;
5762
58- type UnrecognizedKeysIssue = ZodIssue & {
59- code : "unrecognized_keys" ;
60- keys : PropertyKey [ ] ;
61- } ;
62-
63- function normalizeIssuePath ( path : PropertyKey [ ] ) : Array < string | number > {
64- return path . filter ( ( part ) : part is string | number => typeof part !== "symbol" ) ;
65- }
66-
67- function isUnrecognizedKeysIssue ( issue : ZodIssue ) : issue is UnrecognizedKeysIssue {
68- return issue . code === "unrecognized_keys" ;
69- }
70-
71- function formatPath ( parts : Array < string | number > ) : string {
72- if ( parts . length === 0 ) {
73- return "<root>" ;
74- }
75- let out = "" ;
76- for ( const part of parts ) {
77- if ( typeof part === "number" ) {
78- out += `[${ part } ]` ;
79- continue ;
80- }
81- out = out ? `${ out } .${ part } ` : part ;
82- }
83- return out || "<root>" ;
84- }
85-
86- function resolvePathTarget ( root : unknown , path : Array < string | number > ) : unknown {
87- let current : unknown = root ;
88- for ( const part of path ) {
89- if ( typeof part === "number" ) {
90- if ( ! Array . isArray ( current ) ) {
91- return null ;
92- }
93- if ( part < 0 || part >= current . length ) {
94- return null ;
95- }
96- current = current [ part ] ;
97- continue ;
98- }
99- if ( ! current || typeof current !== "object" || Array . isArray ( current ) ) {
100- return null ;
101- }
102- const record = current as Record < string , unknown > ;
103- if ( ! ( part in record ) ) {
104- return null ;
105- }
106- current = record [ part ] ;
107- }
108- return current ;
109- }
110-
111- function stripUnknownConfigKeys ( config : OpenClawConfig ) : {
112- config : OpenClawConfig ;
113- removed : string [ ] ;
114- } {
115- const parsed = OpenClawSchema . safeParse ( config ) ;
116- if ( parsed . success ) {
117- return { config, removed : [ ] } ;
118- }
119-
120- const next = structuredClone ( config ) ;
121- const removed : string [ ] = [ ] ;
122- for ( const issue of parsed . error . issues ) {
123- if ( ! isUnrecognizedKeysIssue ( issue ) ) {
124- continue ;
125- }
126- const path = normalizeIssuePath ( issue . path ) ;
127- const target = resolvePathTarget ( next , path ) ;
128- if ( ! target || typeof target !== "object" || Array . isArray ( target ) ) {
129- continue ;
130- }
131- const record = target as Record < string , unknown > ;
132- for ( const key of issue . keys ) {
133- if ( typeof key !== "string" ) {
134- continue ;
135- }
136- if ( ! ( key in record ) ) {
137- continue ;
138- }
139- delete record [ key ] ;
140- removed . push ( formatPath ( [ ...path , key ] ) ) ;
141- }
142- }
143-
144- return { config : next , removed } ;
145- }
146-
147- function noteOpencodeProviderOverrides ( cfg : OpenClawConfig ) {
148- const providers = cfg . models ?. providers ;
149- if ( ! providers ) {
150- return ;
151- }
152-
153- // 2026-01-10: warn when OpenCode Zen overrides mask built-in routing/costs (8a194b4abc360c6098f157956bb9322576b44d51, 2d105d16f8a099276114173836d46b46cdfbdbae).
154- const overrides : string [ ] = [ ] ;
155- if ( providers . opencode ) {
156- overrides . push ( "opencode" ) ;
157- }
158- if ( providers [ "opencode-zen" ] ) {
159- overrides . push ( "opencode-zen" ) ;
160- }
161- if ( overrides . length === 0 ) {
162- return ;
163- }
164-
165- const lines = overrides . flatMap ( ( id ) => {
166- const providerEntry = providers [ id ] ;
167- const api =
168- isRecord ( providerEntry ) && typeof providerEntry . api === "string"
169- ? providerEntry . api
170- : undefined ;
171- return [
172- `- models.providers.${ id } is set; this overrides the built-in OpenCode Zen catalog.` ,
173- api ? `- models.providers.${ id } .api=${ api } ` : null ,
174- ] . filter ( ( line ) : line is string => Boolean ( line ) ) ;
175- } ) ;
176-
177- lines . push (
178- "- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed)." ,
179- ) ;
180-
181- note ( lines . join ( "\n" ) , "OpenCode Zen" ) ;
182- }
183-
184- function noteIncludeConfinementWarning ( snapshot : {
185- path ?: string | null ;
186- issues ?: Array < { message : string } > ;
187- } ) : void {
188- const issues = snapshot . issues ?? [ ] ;
189- const includeIssue = issues . find (
190- ( issue ) =>
191- issue . message . includes ( "Include path escapes config directory" ) ||
192- issue . message . includes ( "Include path resolves outside config directory" ) ,
193- ) ;
194- if ( ! includeIssue ) {
195- return ;
196- }
197- const configRoot = path . dirname ( snapshot . path ?? CONFIG_PATH ) ;
198- note (
199- [
200- `- $include paths must stay under: ${ configRoot } ` ,
201- '- Move shared include files under that directory and update to relative paths like "./shared/common.json".' ,
202- `- Error: ${ includeIssue . message } ` ,
203- ] . join ( "\n" ) ,
204- "Doctor warnings" ,
205- ) ;
206- }
207-
20863type TelegramAllowFromUsernameHit = { path : string ; entry : string } ;
20964
21065type TelegramAllowFromListRef = {
@@ -1659,7 +1514,7 @@ function collectLegacyToolsBySenderKeyHits(
16591514 const toolsBySender = asObjectRecord ( record . toolsBySender ) ;
16601515 if ( toolsBySender ) {
16611516 const path = [ ...pathParts , "toolsBySender" ] ;
1662- const pathLabel = formatPath ( path ) ;
1517+ const pathLabel = formatConfigPath ( path ) ;
16631518 for ( const rawKey of Object . keys ( toolsBySender ) ) {
16641519 const trimmed = rawKey . trim ( ) ;
16651520 if ( ! trimmed || trimmed === "*" || parseToolsBySenderTypedKey ( trimmed ) ) {
@@ -1702,7 +1557,7 @@ function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): {
17021557 let changed = false ;
17031558
17041559 for ( const hit of hits ) {
1705- const toolsBySender = asObjectRecord ( resolvePathTarget ( next , hit . toolsBySenderPath ) ) ;
1560+ const toolsBySender = asObjectRecord ( resolveConfigPathTarget ( next , hit . toolsBySenderPath ) ) ;
17061561 if ( ! toolsBySender || ! ( hit . key in toolsBySender ) ) {
17071562 continue ;
17081563 }
0 commit comments