Compare commits

...

10 Commits

Author SHA1 Message Date
Vincent Koc
9e34dce4be fix(cli): reject empty scan concurrency 2026-05-18 10:21:30 +08:00
Vincent Koc
1846919706 fix(cli): reject blank numeric options 2026-05-18 10:15:29 +08:00
Vincent Koc
cd39018c61 fix(cli): reject fractional channel log limits 2026-05-18 10:03:15 +08:00
Vincent Koc
f295de3cca fix(cli): simplify channel log error formatting 2026-05-18 10:03:15 +08:00
Vincent Koc
24aa7a4f68 fix(cli): reject fractional model scan counts 2026-05-18 10:03:15 +08:00
Vincent Koc
aed2b2eb4e fix(cli): reject malformed session active filters 2026-05-18 10:03:15 +08:00
Vincent Koc
ea6223ea40 fix(cli): reject malformed agent timeouts 2026-05-18 10:03:15 +08:00
Vincent Koc
7c0aea8535 fix(cli): validate channel log filters 2026-05-18 10:03:15 +08:00
Vincent Koc
c86cefa967 fix(config): label root env substitution errors 2026-05-18 10:03:15 +08:00
Vincent Koc
01fc86696b fix(cli): improve setup config diagnostics 2026-05-18 10:03:15 +08:00
17 changed files with 400 additions and 32 deletions

View File

@@ -81,6 +81,12 @@ Docs: https://docs.openclaw.ai
- Agents/OpenAI: stop post-processing GPT-5 final replies with hardcoded brevity caps, preserving full channel responses instead of appending synthetic ellipses, and log when strict-agentic GPT-5 execution activates. Fixes #82910.
- Mac app: refine the Settings General and Connection panes with cleaner status panels, card rows, and a single native titlebar sidebar toggle.
- Agents/media: deliver failed async image, music, and video generation completions directly when requester-session completion handoff fails, so channel users see provider errors instead of silent fallback stalls.
- CLI/setup: reject invalid `openclaw configure --section` values before opening the full wizard and show config issue details when non-interactive setup is blocked by invalid config.
- CLI/channels: reject unknown `openclaw channels logs --channel` values and invalid `--lines` values instead of silently showing all/default logs.
- CLI/agent: reject `--timeout` values with junk suffixes or fractions instead of partially parsing them.
- CLI/sessions: reject `--active` values with junk suffixes instead of partially parsing them.
- CLI/models: reject fractional `models scan --max-candidates` and `--concurrency` values before starting a scan.
- Config: label root-level `${VAR}` substitution failures as `<root>` instead of printing a blank config path.
- Agents/music: steer song, jingle, beat, anthem, and instrumental requests toward `music_generate` audio creation instead of lyric-only replies, and reserve `lyrics` for exact sung words.
- Codex app-server: record native Codex tool calls and results into trajectory artifacts so debug/trajectory exports capture the full Codex-native tool history, not just OpenClaw-bridged turns. Thanks @vyctorbrzezowski.
- Codex/app-server: keep bound conversation sessions on the owning agent runtime so native Codex control and follow-up turns do not fall back to the default agent client. Fixes #82954. (#82993)

View File

@@ -171,6 +171,45 @@ describe("agentCliCommand", () => {
});
});
it("rejects timeout values with junk suffixes", async () => {
await withTempStore(async () => {
await expect(
agentCliCommand({ message: "hi", to: "+1555", timeout: "10wat" }, runtime),
).rejects.toThrow(
"Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.",
);
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).not.toHaveBeenCalled();
});
});
it("rejects fractional timeout values", async () => {
await withTempStore(async () => {
await expect(
agentCliCommand({ message: "hi", to: "+1555", timeout: "1.5" }, runtime),
).rejects.toThrow(
"Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.",
);
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).not.toHaveBeenCalled();
});
});
it("rejects blank timeout values instead of disabling the timeout", async () => {
await withTempStore(async () => {
await expect(
agentCliCommand({ message: "hi", to: "+1555", timeout: " " }, runtime),
).rejects.toThrow(
"Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.",
);
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).not.toHaveBeenCalled();
});
});
it("uses gateway by default", async () => {
await withTempStore(async () => {
mockGatewaySuccessReply();

View File

@@ -71,16 +71,20 @@ function protectJsonStdout(opts: Pick<AgentCliOpts, "json">): void {
}
function parseTimeoutSeconds(opts: { cfg: OpenClawConfig; timeout?: string }) {
const raw =
opts.timeout !== undefined
? Number.parseInt(opts.timeout, 10)
: (opts.cfg.agents?.defaults?.timeoutSeconds ?? 600);
if (Number.isNaN(raw) || raw < 0) {
const raw = opts.timeout !== undefined ? opts.timeout.trim() : undefined;
if (raw !== undefined && !/^\d+$/.test(raw)) {
throw new Error(
`Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.`,
);
}
return raw;
const parsed =
raw !== undefined ? Number(raw) : (opts.cfg.agents?.defaults?.timeoutSeconds ?? 600);
if (!Number.isInteger(parsed) || parsed < 0) {
throw new Error(
`Invalid --timeout. Use seconds as a non-negative integer, for example --timeout 600. Use --timeout 0 to disable the timeout.`,
);
}
return parsed;
}
function formatPayloadForLog(payload: {

View File

@@ -87,6 +87,54 @@ describe("channelsLogsCommand", () => {
expect(payload.lines.map((line) => line.message)).toEqual(["external sent"]);
});
it("rejects unknown channel filters instead of falling back to all logs", async () => {
await fs.writeFile(
logPath,
[
logLine({ module: "gateway/channels/external-chat/send", message: "external sent" }),
logLine({ module: "gateway/channels/slack/send", message: "slack sent" }),
].join("\n"),
);
await channelsLogsCommand({ channel: "typo", json: true }, runtime);
expect(runtime.error).toHaveBeenCalledWith(
'Unknown channel "typo" for logs. Run `openclaw channels list --all` to see configured and installable channels.',
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.log).not.toHaveBeenCalled();
});
it("rejects invalid line limits instead of silently using the default", async () => {
await fs.writeFile(
logPath,
logLine({ module: "gateway/channels/slack/send", message: "slack sent" }),
);
await channelsLogsCommand({ channel: "slack", lines: "wat", json: true }, runtime);
expect(runtime.error).toHaveBeenCalledWith(
"Invalid --lines. Use a positive integer, for example --lines 200.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.log).not.toHaveBeenCalled();
});
it("rejects fractional line limits instead of truncating", async () => {
await fs.writeFile(
logPath,
logLine({ module: "gateway/channels/slack/send", message: "slack sent" }),
);
await channelsLogsCommand({ channel: "slack", lines: "2.5", json: true }, runtime);
expect(runtime.error).toHaveBeenCalledWith(
"Invalid --lines. Use a positive integer, for example --lines 200.",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.log).not.toHaveBeenCalled();
});
it("falls back to the latest rolling log when the configured rolling file is missing", async () => {
const configuredFile = path.join(tempDir, "openclaw-2026-04-26.log");
const fallbackFile = path.join(tempDir, "openclaw-2026-04-25.log");

View File

@@ -1,5 +1,6 @@
import fs from "node:fs/promises";
import { normalizeChannelId as normalizeBundledChannelId } from "../../channels/registry.js";
import { formatUnknownChannelMessage } from "../../cli/error-format.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { resolveLogFile } from "../../logging/log-tail.js";
import { parseLogLine } from "../../logging/parse-log-line.js";
@@ -37,7 +38,19 @@ function parseChannelFilter(raw?: string) {
if (bundled) {
return bundled;
}
return listManifestChannelIds().has(trimmed) ? trimmed : "all";
return listManifestChannelIds().has(trimmed) ? trimmed : null;
}
function parseLineLimit(raw: string | number | undefined): number | null {
if (raw === undefined) {
return DEFAULT_LIMIT;
}
const value = typeof raw === "string" ? raw.trim() : String(raw);
if (!/^\d+$/.test(value)) {
return null;
}
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function matchesChannel(line: NonNullable<LogLine>, channel: string) {
@@ -91,11 +104,22 @@ export async function channelsLogsCommand(
runtime: RuntimeEnv = defaultRuntime,
) {
const channel = parseChannelFilter(opts.channel);
const limitRaw = typeof opts.lines === "string" ? Number(opts.lines) : opts.lines;
const limit =
typeof limitRaw === "number" && Number.isFinite(limitRaw) && limitRaw > 0
? Math.floor(limitRaw)
: DEFAULT_LIMIT;
if (!channel) {
runtime.error(
formatUnknownChannelMessage({
channel: opts.channel ?? "",
purpose: "logs",
}),
);
runtime.exit(1);
return;
}
const limit = parseLineLimit(opts.lines);
if (limit === null) {
runtime.error("Invalid --lines. Use a positive integer, for example --lines 200.");
runtime.exit(1);
return;
}
const file = await resolveLogFile(getResolvedLoggerSettings().file);
const rawLines = await readTailLines(file, limit * 4);

View File

@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { formatCliCommand } from "../cli/command-format.js";
import type { RuntimeEnv } from "../runtime.js";
import { CONFIGURE_WIZARD_SECTIONS } from "./configure.shared.js";
const mocks = vi.hoisted(() => ({
runConfigureWizard: vi.fn(async () => {}),
}));
vi.mock("./configure.wizard.js", () => ({
runConfigureWizard: mocks.runConfigureWizard,
}));
import { configureCommandFromSectionsArg } from "./configure.commands.js";
function makeRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn() as unknown as RuntimeEnv["exit"],
};
}
describe("configureCommandFromSectionsArg", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("runs the full configure wizard when no sections are provided", async () => {
const runtime = makeRuntime();
await configureCommandFromSectionsArg(undefined, runtime);
expect(mocks.runConfigureWizard).toHaveBeenCalledWith({ command: "configure" }, runtime);
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
it("runs only the requested valid sections", async () => {
const runtime = makeRuntime();
await configureCommandFromSectionsArg(["gateway", "model"], runtime);
expect(mocks.runConfigureWizard).toHaveBeenCalledWith(
{ command: "configure", sections: ["gateway", "model"] },
runtime,
);
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
it("rejects invalid-only section input instead of falling back to the full wizard", async () => {
const runtime = makeRuntime();
await configureCommandFromSectionsArg(["typo"], runtime);
expect(runtime.error).toHaveBeenCalledWith(
`Invalid --section: typo. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.runConfigureWizard).not.toHaveBeenCalled();
});
it("rejects mixed valid and invalid section input without running a partial wizard", async () => {
const runtime = makeRuntime();
await configureCommandFromSectionsArg(["gateway", "bogus"], runtime);
expect(runtime.error).toHaveBeenCalledWith(
`Invalid --section: bogus. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`,
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.runConfigureWizard).not.toHaveBeenCalled();
});
});

View File

@@ -21,11 +21,6 @@ export async function configureCommandFromSectionsArg(
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
const { sections, invalid } = parseConfigureWizardSections(rawSections);
if (sections.length === 0) {
await configureCommand(runtime);
return;
}
if (invalid.length > 0) {
runtime.error(
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`,
@@ -34,5 +29,10 @@ export async function configureCommandFromSectionsArg(
return;
}
if (sections.length === 0) {
await configureCommand(runtime);
return;
}
await configureCommandWithSections(sections as never, runtime);
}

View File

@@ -148,4 +148,30 @@ describe("models scan command", () => {
expect(mocks.scanOpenRouterModels).not.toHaveBeenCalled();
});
it("rejects fractional count options before scanning", async () => {
const runtime = createRuntime();
await expect(modelsScanCommand({ maxCandidates: "1.5" }, runtime)).rejects.toThrow(
"--max-candidates must be a positive integer",
);
await expect(modelsScanCommand({ concurrency: "2.5" }, runtime)).rejects.toThrow(
"--concurrency must be a positive integer",
);
expect(mocks.scanOpenRouterModels).not.toHaveBeenCalled();
});
it("rejects blank count options before scanning", async () => {
const runtime = createRuntime();
await expect(modelsScanCommand({ maxCandidates: "" }, runtime)).rejects.toThrow(
"--max-candidates must be a positive integer",
);
await expect(modelsScanCommand({ concurrency: "" }, runtime)).rejects.toThrow(
"--concurrency must be a positive integer",
);
expect(mocks.scanOpenRouterModels).not.toHaveBeenCalled();
});
});

View File

@@ -153,6 +153,21 @@ function printScanTable(results: ModelScanResult[], runtime: RuntimeEnv) {
}
}
function parsePositiveIntegerOption(
raw: string | undefined,
fallback: number | undefined,
): number | undefined {
if (raw === undefined) {
return fallback;
}
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
return undefined;
}
const value = Number(trimmed);
return Number.isInteger(value) && value > 0 ? value : undefined;
}
export async function modelsScanCommand(
opts: {
minParams?: string;
@@ -178,17 +193,17 @@ export async function modelsScanCommand(
if (maxAgeDays !== undefined && (!Number.isFinite(maxAgeDays) || maxAgeDays < 0)) {
throw new Error("--max-age-days must be >= 0");
}
const maxCandidates = opts.maxCandidates ? Number(opts.maxCandidates) : 6;
if (!Number.isFinite(maxCandidates) || maxCandidates <= 0) {
throw new Error("--max-candidates must be > 0");
const maxCandidates = parsePositiveIntegerOption(opts.maxCandidates, 6);
if (maxCandidates === undefined) {
throw new Error("--max-candidates must be a positive integer");
}
const timeout = opts.timeout ? Number(opts.timeout) : undefined;
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
throw new Error("--timeout must be > 0");
}
const concurrency = opts.concurrency ? Number(opts.concurrency) : undefined;
if (concurrency !== undefined && (!Number.isFinite(concurrency) || concurrency <= 0)) {
throw new Error("--concurrency must be > 0");
const concurrency = parsePositiveIntegerOption(opts.concurrency, undefined);
if (opts.concurrency !== undefined && concurrency === undefined) {
throw new Error("--concurrency must be a positive integer");
}
const requestedProbe = opts.probe ?? true;

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),
runNonInteractiveLocalSetup: vi.fn(async () => {}),
runNonInteractiveRemoteSetup: vi.fn(async () => {}),
}));
vi.mock("../config/io.js", () => ({
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
}));
vi.mock("./onboard-non-interactive/local.js", () => ({
runNonInteractiveLocalSetup: mocks.runNonInteractiveLocalSetup,
}));
vi.mock("./onboard-non-interactive/remote.js", () => ({
runNonInteractiveRemoteSetup: mocks.runNonInteractiveRemoteSetup,
}));
import { runNonInteractiveSetup } from "./onboard-non-interactive.js";
function makeRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn() as unknown as RuntimeEnv["exit"],
};
}
describe("runNonInteractiveSetup invalid config handling", () => {
it("includes config issue details before exiting", async () => {
const runtime = makeRuntime();
mocks.readConfigFileSnapshot.mockResolvedValue({
exists: true,
valid: false,
config: {},
sourceConfig: {},
issues: [
{
path: "",
message: "JSON5 parse failed: JSON5: invalid character '}' at 2:20",
},
],
});
await runNonInteractiveSetup({ nonInteractive: true, acceptRisk: true }, runtime);
expect(runtime.error).toHaveBeenCalledWith(
[
"Config invalid.",
"Issues: <root>: JSON5 parse failed: JSON5: invalid character '}' at 2:20",
"Run `openclaw doctor` to repair it, then re-run setup.",
].join("\n"),
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.runNonInteractiveLocalSetup).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveRemoteSetup).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
import { formatCliCommand } from "../cli/command-format.js";
import { replaceConfigFile } from "../config/config.js";
import { formatConfigIssueSummary } from "../config/issue-format.js";
import { readConfigFileSnapshot } from "../config/io.js";
import { logConfigUpdated } from "../config/logging.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -99,8 +100,15 @@ export async function runNonInteractiveSetup(
) {
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
const issueSummary = formatConfigIssueSummary(snapshot.issues ?? []);
runtime.error(
`Config invalid. Run \`${formatCliCommand("openclaw doctor")}\` to repair it, then re-run setup.`,
[
"Config invalid.",
issueSummary ? `Issues: ${issueSummary}` : null,
`Run \`${formatCliCommand("openclaw doctor")}\` to repair it, then re-run setup.`,
]
.filter((line): line is string => Boolean(line))
.join("\n"),
);
runtime.exit(1);
return;

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { stripAnsi } from "../terminal/ansi.js";
import {
makeRuntime,
mockSessionsConfig,
@@ -48,7 +49,7 @@ describe("sessionsCommand", () => {
expect(logs.join("\n")).toContain("Tokens (ctx %");
const row = logs.find((line) => line.includes("+15555550123")) ?? "";
expect(row).toBe(
expect(stripAnsi(row)).toBe(
"direct +15555550123 45m ago pi:opus OpenAI Codex 2.0k/32k (6%) id:abc123",
);
});
@@ -85,7 +86,7 @@ describe("sessionsCommand", () => {
expect(logs.join("\n")).toContain("Runtime");
const row = logs.find((line) => line.includes("agent:main:main")) ?? "";
expect(row).toBe(
expect(stripAnsi(row)).toBe(
"direct agent:main:main 1m ago claude-opus-4-7 Claude CLI unknown/200k (?%) id:main-session",
);
});
@@ -120,7 +121,7 @@ describe("sessionsCommand", () => {
fs.rmSync(store);
const row = logs.find((line) => line.includes("agent:main:main")) ?? "";
expect(row).toBe(
expect(stripAnsi(row)).toBe(
"direct agent:main:main 1m ago claude-opus-4-7 Claude CLI unknown/200k (?%) id:main-session",
);
});
@@ -140,7 +141,7 @@ describe("sessionsCommand", () => {
fs.rmSync(store);
const row = logs.find((line) => line.includes("quietchat:group:demo")) ?? "";
expect(row).toBe(
expect(stripAnsi(row)).toBe(
"group quietchat:group:demo 5m ago pi:opus OpenAI Codex unknown/32k (?%) think:high id:xyz",
);
});
@@ -325,6 +326,26 @@ describe("sessionsCommand", () => {
fs.rmSync(store);
});
it("rejects --active values with junk suffixes", async () => {
const store = writeStore(
{
demo: {
sessionId: "demo",
updatedAt: Date.now() - 5 * 60_000,
},
},
"sessions-active-junk",
);
const { runtime, errors } = makeRuntime();
await expect(sessionsCommand({ store, active: "10wat" }, runtime)).rejects.toThrow("exit 1");
expect(errors).toStrictEqual([
"--active must be a positive number of minutes, for example --active 30.",
]);
fs.rmSync(store);
});
it("rejects invalid --limit values", async () => {
const store = writeStore(
{

View File

@@ -134,6 +134,18 @@ function parseSessionsLimit(value: string | number | undefined): number | undefi
return Number.isInteger(value) && value > 0 ? value : null;
}
function parseActiveMinutes(value: string | undefined): number | null {
if (value === undefined) {
return null;
}
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
const parsed = Number.parseInt(trimmed, 10);
return parsed > 0 ? parsed : null;
}
const colorByPct = (label: string, pct: number | null, rich: boolean) => {
if (!rich || pct === null) {
return label;
@@ -254,8 +266,8 @@ export async function sessionsCommand(
let activeMinutes: number | undefined;
if (opts.active !== undefined) {
const parsed = Number.parseInt(opts.active, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
const parsed = parseActiveMinutes(opts.active);
if (parsed === null) {
runtime.error("--active must be a positive number of minutes, for example --active 30.");
runtime.exit(1);
return;

View File

@@ -116,6 +116,13 @@ describe("resolveConfigEnvVars", () => {
describe("missing env var handling", () => {
it("throws MissingEnvVarError with var name and config path details", () => {
const scenarios: MissingEnvScenario[] = [
{
name: "missing root string var",
config: "${MISSING_ROOT}",
env: {},
varName: "MISSING_ROOT",
configPath: "",
},
{
name: "missing top-level var",
config: { key: "${MISSING}" },
@@ -148,6 +155,12 @@ describe("resolveConfigEnvVars", () => {
expectMissingScenarios(scenarios);
});
it("labels root-level missing env var errors instead of printing a blank path", () => {
expect(() => resolveConfigEnvVars("${MISSING_ROOT}", {})).toThrow(
'Missing env var "MISSING_ROOT" referenced at config path: <root>',
);
});
});
describe("escape syntax", () => {

View File

@@ -31,7 +31,7 @@ export class MissingEnvVarError extends Error {
public readonly varName: string,
public readonly configPath: string,
) {
super(`Missing env var "${varName}" referenced at config path: ${configPath}`);
super(`Missing env var "${varName}" referenced at config path: ${configPath || "<root>"}`);
this.name = "MissingEnvVarError";
}
}

View File

@@ -189,6 +189,19 @@ describe("resolveConfigIncludes", () => {
expectResolveIncludeError(run, pattern);
});
it("includes the JSON parser detail when an included file cannot be parsed", () => {
const error = expectResolveIncludeError(() =>
resolveConfigIncludes({ $include: "./bad.json" }, DEFAULT_BASE_PATH, {
readFile: () => "{ invalid json }",
parseJson: JSON.parse,
}),
);
expect(error.message).toContain("Failed to parse include file: ./bad.json");
expect(error.message).toContain("JSON");
expect(error.message).toContain("line 1 column 3");
});
it("throws CircularIncludeError for circular includes", () => {
const aPath = configPath("a.json");
const bPath = configPath("b.json");

View File

@@ -14,6 +14,7 @@ import fs from "node:fs";
import path from "node:path";
import JSON5 from "json5";
import { canUseRootFileOpen, openRootFileSync } from "../infra/boundary-file-read.js";
import { formatErrorMessage } from "../infra/errors.js";
import { isPathInside } from "../security/scan-paths.js";
import { isPlainObject } from "../utils.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
@@ -319,7 +320,9 @@ class IncludeProcessor {
return this.resolver.parseJson(raw);
} catch (err) {
throw new ConfigIncludeError(
`Failed to parse include file: ${includePath} (resolved: ${resolvedPath})`,
`Failed to parse include file: ${includePath} (resolved: ${resolvedPath}): ${formatErrorMessage(
err,
)}`,
includePath,
err instanceof Error ? err : undefined,
);