Skip to content

Commit 6bd5735

Browse files
committed
refactor: split doctor config analysis helpers
1 parent 11be305 commit 6bd5735

File tree

3 files changed

+196
-155
lines changed

3 files changed

+196
-155
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
formatConfigPath,
4+
resolveConfigPathTarget,
5+
stripUnknownConfigKeys,
6+
} from "./doctor-config-analysis.js";
7+
8+
describe("doctor config analysis helpers", () => {
9+
it("formats config paths predictably", () => {
10+
expect(formatConfigPath([])).toBe("<root>");
11+
expect(formatConfigPath(["channels", "slack", "accounts", 0, "token"])).toBe(
12+
"channels.slack.accounts[0].token",
13+
);
14+
});
15+
16+
it("resolves nested config targets without throwing", () => {
17+
const target = resolveConfigPathTarget(
18+
{ channels: { slack: { accounts: [{ token: "x" }] } } },
19+
["channels", "slack", "accounts", 0],
20+
);
21+
expect(target).toEqual({ token: "x" });
22+
expect(resolveConfigPathTarget({ channels: null }, ["channels", "slack"])).toBeNull();
23+
});
24+
25+
it("strips unknown config keys while keeping known values", () => {
26+
const result = stripUnknownConfigKeys({
27+
hooks: {},
28+
unexpected: true,
29+
} as never);
30+
expect(result.removed).toContain("unexpected");
31+
expect((result.config as Record<string, unknown>).unexpected).toBeUndefined();
32+
expect((result.config as Record<string, unknown>).hooks).toEqual({});
33+
});
34+
});
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import path from "node:path";
2+
import type { ZodIssue } from "zod";
3+
import type { OpenClawConfig } from "../config/config.js";
4+
import { CONFIG_PATH } from "../config/config.js";
5+
import { OpenClawSchema } from "../config/zod-schema.js";
6+
import { note } from "../terminal/note.js";
7+
import { isRecord } from "../utils.js";
8+
9+
type UnrecognizedKeysIssue = ZodIssue & {
10+
code: "unrecognized_keys";
11+
keys: PropertyKey[];
12+
};
13+
14+
function normalizeIssuePath(path: PropertyKey[]): Array<string | number> {
15+
return path.filter((part): part is string | number => typeof part !== "symbol");
16+
}
17+
18+
function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue {
19+
return issue.code === "unrecognized_keys";
20+
}
21+
22+
export function formatConfigPath(parts: Array<string | number>): string {
23+
if (parts.length === 0) {
24+
return "<root>";
25+
}
26+
let out = "";
27+
for (const part of parts) {
28+
if (typeof part === "number") {
29+
out += `[${part}]`;
30+
continue;
31+
}
32+
out = out ? `${out}.${part}` : part;
33+
}
34+
return out || "<root>";
35+
}
36+
37+
export function resolveConfigPathTarget(root: unknown, path: Array<string | number>): unknown {
38+
let current: unknown = root;
39+
for (const part of path) {
40+
if (typeof part === "number") {
41+
if (!Array.isArray(current)) {
42+
return null;
43+
}
44+
if (part < 0 || part >= current.length) {
45+
return null;
46+
}
47+
current = current[part];
48+
continue;
49+
}
50+
if (!current || typeof current !== "object" || Array.isArray(current)) {
51+
return null;
52+
}
53+
const record = current as Record<string, unknown>;
54+
if (!(part in record)) {
55+
return null;
56+
}
57+
current = record[part];
58+
}
59+
return current;
60+
}
61+
62+
export function stripUnknownConfigKeys(config: OpenClawConfig): {
63+
config: OpenClawConfig;
64+
removed: string[];
65+
} {
66+
const parsed = OpenClawSchema.safeParse(config);
67+
if (parsed.success) {
68+
return { config, removed: [] };
69+
}
70+
71+
const next = structuredClone(config);
72+
const removed: string[] = [];
73+
for (const issue of parsed.error.issues) {
74+
if (!isUnrecognizedKeysIssue(issue)) {
75+
continue;
76+
}
77+
const issuePath = normalizeIssuePath(issue.path);
78+
const target = resolveConfigPathTarget(next, issuePath);
79+
if (!target || typeof target !== "object" || Array.isArray(target)) {
80+
continue;
81+
}
82+
const record = target as Record<string, unknown>;
83+
for (const key of issue.keys) {
84+
if (typeof key !== "string" || !(key in record)) {
85+
continue;
86+
}
87+
delete record[key];
88+
removed.push(formatConfigPath([...issuePath, key]));
89+
}
90+
}
91+
92+
return { config: next, removed };
93+
}
94+
95+
export function noteOpencodeProviderOverrides(cfg: OpenClawConfig): void {
96+
const providers = cfg.models?.providers;
97+
if (!providers) {
98+
return;
99+
}
100+
101+
const overrides: string[] = [];
102+
if (providers.opencode) {
103+
overrides.push("opencode");
104+
}
105+
if (providers["opencode-zen"]) {
106+
overrides.push("opencode-zen");
107+
}
108+
if (overrides.length === 0) {
109+
return;
110+
}
111+
112+
const lines = overrides.flatMap((id) => {
113+
const providerEntry = providers[id];
114+
const api =
115+
isRecord(providerEntry) && typeof providerEntry.api === "string"
116+
? providerEntry.api
117+
: undefined;
118+
return [
119+
`- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`,
120+
api ? `- models.providers.${id}.api=${api}` : null,
121+
].filter((line): line is string => Boolean(line));
122+
});
123+
124+
lines.push(
125+
"- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).",
126+
);
127+
note(lines.join("\n"), "OpenCode Zen");
128+
}
129+
130+
export function noteIncludeConfinementWarning(snapshot: {
131+
path?: string | null;
132+
issues?: Array<{ message: string }>;
133+
}): void {
134+
const issues = snapshot.issues ?? [];
135+
const includeIssue = issues.find(
136+
(issue) =>
137+
issue.message.includes("Include path escapes config directory") ||
138+
issue.message.includes("Include path resolves outside config directory"),
139+
);
140+
if (!includeIssue) {
141+
return;
142+
}
143+
const configRoot = path.dirname(snapshot.path ?? CONFIG_PATH);
144+
note(
145+
[
146+
`- $include paths must stay under: ${configRoot}`,
147+
'- Move shared include files under that directory and update to relative paths like "./shared/common.json".',
148+
`- Error: ${includeIssue.message}`,
149+
].join("\n"),
150+
"Doctor warnings",
151+
);
152+
}

src/commands/doctor-config-flow.ts

Lines changed: 10 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
3-
import type { ZodIssue } from "zod";
43
import { normalizeChatChannelId } from "../channels/registry.js";
54
import {
65
isNumericTelegramUserId,
@@ -17,7 +16,6 @@ import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-
1716
import { formatConfigIssueLines } from "../config/issue-format.js";
1817
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
1918
import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
20-
import { OpenClawSchema } from "../config/zod-schema.js";
2119
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
2220
import {
2321
listInterpreterLikeSafeBins,
@@ -50,161 +48,18 @@ import {
5048
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
5149
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
5250
import { 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";
5459
import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
5560
import type { DoctorOptions } from "./doctor-prompter.js";
5661
import { 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-
20863
type TelegramAllowFromUsernameHit = { path: string; entry: string };
20964

21065
type 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

Comments
 (0)