fix: isolate CLI startup imports (#50212)
* fix: isolate CLI startup imports * fix: clarify CLI preflight behavior * fix: tighten main-module detection * fix: isolate CLI startup imports (#50212)
This commit is contained in:
parent
68bc6effc0
commit
d978ace90b
@ -220,6 +220,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
|
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
|
||||||
- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
|
- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
|
||||||
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization.
|
||||||
|
- CLI/startup: stop `openclaw devices list` and similar loopback gateway commands from failing during startup by isolating heavy import-time side effects from the normal CLI path. (#50212) Thanks @obviyus.
|
||||||
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
||||||
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
||||||
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants.
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const readFileSyncMock = vi.hoisted(() => vi.fn());
|
const readFileSyncMock = vi.hoisted(() => vi.fn());
|
||||||
const listCatalogMock = vi.hoisted(() => vi.fn());
|
|
||||||
const listPluginsMock = vi.hoisted(() => vi.fn());
|
|
||||||
const ensurePluginRegistryLoadedMock = vi.hoisted(() => vi.fn());
|
|
||||||
|
|
||||||
vi.mock("node:fs", async () => {
|
vi.mock("node:fs", async () => {
|
||||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||||
@ -22,25 +19,12 @@ vi.mock("../channels/registry.js", () => ({
|
|||||||
CHAT_CHANNEL_ORDER: ["telegram", "discord"],
|
CHAT_CHANNEL_ORDER: ["telegram", "discord"],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../channels/plugins/catalog.js", () => ({
|
|
||||||
listChannelPluginCatalogEntries: listCatalogMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("../channels/plugins/index.js", () => ({
|
|
||||||
listChannelPlugins: listPluginsMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./plugin-registry.js", () => ({
|
|
||||||
ensurePluginRegistryLoaded: ensurePluginRegistryLoadedMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
async function loadModule() {
|
async function loadModule() {
|
||||||
return await import("./channel-options.js");
|
return await import("./channel-options.js");
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveCliChannelOptions", () => {
|
describe("resolveCliChannelOptions", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS;
|
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@ -49,50 +33,26 @@ describe("resolveCliChannelOptions", () => {
|
|||||||
readFileSyncMock.mockReturnValue(
|
readFileSyncMock.mockReturnValue(
|
||||||
JSON.stringify({ channelOptions: ["cached", "telegram", "cached"] }),
|
JSON.stringify({ channelOptions: ["cached", "telegram", "cached"] }),
|
||||||
);
|
);
|
||||||
listCatalogMock.mockReturnValue([{ id: "catalog-only" }]);
|
|
||||||
|
|
||||||
const mod = await loadModule();
|
const mod = await loadModule();
|
||||||
expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram", "catalog-only"]);
|
expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram"]);
|
||||||
expect(listCatalogMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to dynamic catalog resolution when metadata is missing", async () => {
|
it("falls back to core channel order when metadata is missing", async () => {
|
||||||
readFileSyncMock.mockImplementation(() => {
|
readFileSyncMock.mockImplementation(() => {
|
||||||
throw new Error("ENOENT");
|
throw new Error("ENOENT");
|
||||||
});
|
});
|
||||||
listCatalogMock.mockReturnValue([{ id: "feishu" }, { id: "telegram" }]);
|
|
||||||
|
|
||||||
const mod = await loadModule();
|
const mod = await loadModule();
|
||||||
expect(mod.resolveCliChannelOptions()).toEqual(["telegram", "discord", "feishu"]);
|
expect(mod.resolveCliChannelOptions()).toEqual(["telegram", "discord"]);
|
||||||
expect(listCatalogMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects eager mode and includes loaded plugin ids", async () => {
|
it("ignores external catalog env during CLI bootstrap", async () => {
|
||||||
process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS = "1";
|
|
||||||
readFileSyncMock.mockReturnValue(JSON.stringify({ channelOptions: ["cached"] }));
|
|
||||||
listCatalogMock.mockReturnValue([{ id: "zalo" }]);
|
|
||||||
listPluginsMock.mockReturnValue([{ id: "custom-a" }, { id: "custom-b" }]);
|
|
||||||
|
|
||||||
const mod = await loadModule();
|
|
||||||
expect(mod.resolveCliChannelOptions()).toEqual([
|
|
||||||
"telegram",
|
|
||||||
"discord",
|
|
||||||
"zalo",
|
|
||||||
"custom-a",
|
|
||||||
"custom-b",
|
|
||||||
]);
|
|
||||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledOnce();
|
|
||||||
expect(listPluginsMock).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps dynamic catalog resolution when external catalog env is set", async () => {
|
|
||||||
process.env.OPENCLAW_PLUGIN_CATALOG_PATHS = "/tmp/plugins-catalog.json";
|
process.env.OPENCLAW_PLUGIN_CATALOG_PATHS = "/tmp/plugins-catalog.json";
|
||||||
readFileSyncMock.mockReturnValue(JSON.stringify({ channelOptions: ["cached", "telegram"] }));
|
readFileSyncMock.mockReturnValue(JSON.stringify({ channelOptions: ["cached", "telegram"] }));
|
||||||
listCatalogMock.mockReturnValue([{ id: "custom-catalog" }]);
|
|
||||||
|
|
||||||
const mod = await loadModule();
|
const mod = await loadModule();
|
||||||
expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram", "custom-catalog"]);
|
expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram"]);
|
||||||
expect(listCatalogMock).toHaveBeenCalledOnce();
|
|
||||||
delete process.env.OPENCLAW_PLUGIN_CATALOG_PATHS;
|
delete process.env.OPENCLAW_PLUGIN_CATALOG_PATHS;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js";
|
|
||||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
|
||||||
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
|
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
|
||||||
import { isTruthyEnvValue } from "../infra/env.js";
|
|
||||||
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
|
|
||||||
|
|
||||||
function dedupe(values: string[]): string[] {
|
function dedupe(values: string[]): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@ -48,19 +44,8 @@ function loadPrecomputedChannelOptions(): string[] | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCliChannelOptions(): string[] {
|
export function resolveCliChannelOptions(): string[] {
|
||||||
if (isTruthyEnvValue(process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS)) {
|
|
||||||
const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id);
|
|
||||||
const base = dedupe([...CHAT_CHANNEL_ORDER, ...catalog]);
|
|
||||||
ensurePluginRegistryLoaded();
|
|
||||||
const pluginIds = listChannelPlugins().map((plugin) => plugin.id);
|
|
||||||
return dedupe([...base, ...pluginIds]);
|
|
||||||
}
|
|
||||||
const precomputed = loadPrecomputedChannelOptions();
|
const precomputed = loadPrecomputedChannelOptions();
|
||||||
const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id);
|
return precomputed ?? [...CHAT_CHANNEL_ORDER];
|
||||||
const base = precomputed
|
|
||||||
? dedupe([...precomputed, ...catalog])
|
|
||||||
: dedupe([...CHAT_CHANNEL_ORDER, ...catalog]);
|
|
||||||
return base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCliChannelOptions(extra: string[] = []): string {
|
export function formatCliChannelOptions(extra: string[] = []): string {
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import type { RuntimeEnv } from "../../runtime.js";
|
|||||||
const loadAndMaybeMigrateDoctorConfigMock = vi.hoisted(() => vi.fn());
|
const loadAndMaybeMigrateDoctorConfigMock = vi.hoisted(() => vi.fn());
|
||||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock("../../commands/doctor-config-flow.js", () => ({
|
vi.mock("../../commands/doctor-config-preflight.js", () => ({
|
||||||
loadAndMaybeMigrateDoctorConfig: loadAndMaybeMigrateDoctorConfigMock,
|
runDoctorConfigPreflight: loadAndMaybeMigrateDoctorConfigMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../config/config.js", () => ({
|
vi.mock("../../config/config.js", () => ({
|
||||||
@ -58,12 +58,17 @@ describe("ensureConfigReady", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setInvalidSnapshot(overrides?: Partial<ReturnType<typeof makeSnapshot>>) {
|
function setInvalidSnapshot(overrides?: Partial<ReturnType<typeof makeSnapshot>>) {
|
||||||
readConfigFileSnapshotMock.mockResolvedValue({
|
const snapshot = {
|
||||||
...makeSnapshot(),
|
...makeSnapshot(),
|
||||||
exists: true,
|
exists: true,
|
||||||
valid: false,
|
valid: false,
|
||||||
issues: [{ path: "channels.whatsapp", message: "invalid" }],
|
issues: [{ path: "channels.whatsapp", message: "invalid" }],
|
||||||
...overrides,
|
...overrides,
|
||||||
|
};
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValue(snapshot);
|
||||||
|
loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({
|
||||||
|
snapshot,
|
||||||
|
baseConfig: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,6 +83,10 @@ describe("ensureConfigReady", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
resetConfigGuardStateForTests();
|
resetConfigGuardStateForTests();
|
||||||
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
|
readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot());
|
||||||
|
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => ({
|
||||||
|
snapshot: makeSnapshot(),
|
||||||
|
baseConfig: {},
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@ -94,6 +103,13 @@ describe("ensureConfigReady", () => {
|
|||||||
])("$name", async ({ commandPath, expectedDoctorCalls }) => {
|
])("$name", async ({ commandPath, expectedDoctorCalls }) => {
|
||||||
await runEnsureConfigReady(commandPath);
|
await runEnsureConfigReady(commandPath);
|
||||||
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(expectedDoctorCalls);
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(expectedDoctorCalls);
|
||||||
|
if (expectedDoctorCalls > 0) {
|
||||||
|
expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({
|
||||||
|
migrateState: false,
|
||||||
|
migrateLegacyConfig: false,
|
||||||
|
invalidConfigNote: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exits for invalid config on non-allowlisted commands", async () => {
|
it("exits for invalid config on non-allowlisted commands", async () => {
|
||||||
@ -132,6 +148,10 @@ describe("ensureConfigReady", () => {
|
|||||||
it("prevents preflight stdout noise when suppression is enabled", async () => {
|
it("prevents preflight stdout noise when suppression is enabled", async () => {
|
||||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||||
process.stdout.write("Doctor warnings\n");
|
process.stdout.write("Doctor warnings\n");
|
||||||
|
return {
|
||||||
|
snapshot: makeSnapshot(),
|
||||||
|
baseConfig: {},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
const output = await withCapturedStdout(async () => {
|
const output = await withCapturedStdout(async () => {
|
||||||
await runEnsureConfigReady(["message"], true);
|
await runEnsureConfigReady(["message"], true);
|
||||||
@ -142,6 +162,10 @@ describe("ensureConfigReady", () => {
|
|||||||
it("allows preflight stdout noise when suppression is not enabled", async () => {
|
it("allows preflight stdout noise when suppression is not enabled", async () => {
|
||||||
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => {
|
||||||
process.stdout.write("Doctor warnings\n");
|
process.stdout.write("Doctor warnings\n");
|
||||||
|
return {
|
||||||
|
snapshot: makeSnapshot(),
|
||||||
|
baseConfig: {},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
const output = await withCapturedStdout(async () => {
|
const output = await withCapturedStdout(async () => {
|
||||||
await runEnsureConfigReady(["message"], false);
|
await runEnsureConfigReady(["message"], false);
|
||||||
|
|||||||
@ -39,22 +39,25 @@ export async function ensureConfigReady(params: {
|
|||||||
suppressDoctorStdout?: boolean;
|
suppressDoctorStdout?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const commandPath = params.commandPath ?? [];
|
const commandPath = params.commandPath ?? [];
|
||||||
|
let preflightSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>> | null = null;
|
||||||
if (!didRunDoctorConfigFlow && shouldMigrateStateFromPath(commandPath)) {
|
if (!didRunDoctorConfigFlow && shouldMigrateStateFromPath(commandPath)) {
|
||||||
didRunDoctorConfigFlow = true;
|
didRunDoctorConfigFlow = true;
|
||||||
const runDoctorConfigFlow = async () =>
|
const runDoctorConfigPreflight = async () =>
|
||||||
(await import("../../commands/doctor-config-flow.js")).loadAndMaybeMigrateDoctorConfig({
|
(await import("../../commands/doctor-config-preflight.js")).runDoctorConfigPreflight({
|
||||||
options: { nonInteractive: true },
|
// Keep ordinary CLI startup on the lightweight validation path.
|
||||||
confirm: async () => false,
|
migrateState: false,
|
||||||
|
migrateLegacyConfig: false,
|
||||||
|
invalidConfigNote: false,
|
||||||
});
|
});
|
||||||
if (!params.suppressDoctorStdout) {
|
if (!params.suppressDoctorStdout) {
|
||||||
await runDoctorConfigFlow();
|
preflightSnapshot = (await runDoctorConfigPreflight()).snapshot;
|
||||||
} else {
|
} else {
|
||||||
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||||
const originalSuppressNotes = process.env.OPENCLAW_SUPPRESS_NOTES;
|
const originalSuppressNotes = process.env.OPENCLAW_SUPPRESS_NOTES;
|
||||||
process.stdout.write = (() => true) as unknown as typeof process.stdout.write;
|
process.stdout.write = (() => true) as unknown as typeof process.stdout.write;
|
||||||
process.env.OPENCLAW_SUPPRESS_NOTES = "1";
|
process.env.OPENCLAW_SUPPRESS_NOTES = "1";
|
||||||
try {
|
try {
|
||||||
await runDoctorConfigFlow();
|
preflightSnapshot = (await runDoctorConfigPreflight()).snapshot;
|
||||||
} finally {
|
} finally {
|
||||||
process.stdout.write = originalStdoutWrite;
|
process.stdout.write = originalStdoutWrite;
|
||||||
if (originalSuppressNotes === undefined) {
|
if (originalSuppressNotes === undefined) {
|
||||||
@ -66,7 +69,7 @@ export async function ensureConfigReady(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = await getConfigSnapshot();
|
const snapshot = preflightSnapshot ?? (await getConfigSnapshot());
|
||||||
const commandName = commandPath[0];
|
const commandName = commandPath[0];
|
||||||
const subcommandName = commandPath[1];
|
const subcommandName = commandPath[1];
|
||||||
const allowInvalid = commandName
|
const allowInvalid = commandName
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import {
|
import {
|
||||||
fetchTelegramChatId,
|
fetchTelegramChatId,
|
||||||
inspectTelegramAccount,
|
inspectTelegramAccount,
|
||||||
@ -13,7 +11,7 @@ import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gatewa
|
|||||||
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
||||||
import { listRouteBindings } from "../config/bindings.js";
|
import { listRouteBindings } from "../config/bindings.js";
|
||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
|
import { CONFIG_PATH, migrateLegacyConfig } from "../config/config.js";
|
||||||
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
|
||||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||||
@ -51,17 +49,15 @@ import {
|
|||||||
isZalouserMutableGroupEntry,
|
isZalouserMutableGroupEntry,
|
||||||
} from "../security/mutable-allowlist-detectors.js";
|
} from "../security/mutable-allowlist-detectors.js";
|
||||||
import { note } from "../terminal/note.js";
|
import { note } from "../terminal/note.js";
|
||||||
import { resolveHomeDir } from "../utils.js";
|
|
||||||
import {
|
import {
|
||||||
formatConfigPath,
|
formatConfigPath,
|
||||||
noteIncludeConfinementWarning,
|
|
||||||
noteOpencodeProviderOverrides,
|
noteOpencodeProviderOverrides,
|
||||||
resolveConfigPathTarget,
|
resolveConfigPathTarget,
|
||||||
stripUnknownConfigKeys,
|
stripUnknownConfigKeys,
|
||||||
} from "./doctor-config-analysis.js";
|
} from "./doctor-config-analysis.js";
|
||||||
|
import { runDoctorConfigPreflight } from "./doctor-config-preflight.js";
|
||||||
import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
|
import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
|
||||||
import type { DoctorOptions } from "./doctor-prompter.js";
|
import type { DoctorOptions } from "./doctor-prompter.js";
|
||||||
import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js";
|
|
||||||
|
|
||||||
type TelegramAllowFromUsernameHit = { path: string; entry: string };
|
type TelegramAllowFromUsernameHit = { path: string; entry: string };
|
||||||
|
|
||||||
@ -1640,87 +1636,19 @@ function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): {
|
|||||||
return { config: next, changes };
|
return { config: next, changes };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function maybeMigrateLegacyConfig(): Promise<string[]> {
|
|
||||||
const changes: string[] = [];
|
|
||||||
const home = resolveHomeDir();
|
|
||||||
if (!home) {
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetDir = path.join(home, ".openclaw");
|
|
||||||
const targetPath = path.join(targetDir, "openclaw.json");
|
|
||||||
try {
|
|
||||||
await fs.access(targetPath);
|
|
||||||
return changes;
|
|
||||||
} catch {
|
|
||||||
// missing config
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyCandidates = [
|
|
||||||
path.join(home, ".clawdbot", "clawdbot.json"),
|
|
||||||
path.join(home, ".moldbot", "moldbot.json"),
|
|
||||||
path.join(home, ".moltbot", "moltbot.json"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let legacyPath: string | null = null;
|
|
||||||
for (const candidate of legacyCandidates) {
|
|
||||||
try {
|
|
||||||
await fs.access(candidate);
|
|
||||||
legacyPath = candidate;
|
|
||||||
break;
|
|
||||||
} catch {
|
|
||||||
// continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!legacyPath) {
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.mkdir(targetDir, { recursive: true });
|
|
||||||
try {
|
|
||||||
await fs.copyFile(legacyPath, targetPath, fs.constants.COPYFILE_EXCL);
|
|
||||||
changes.push(`Migrated legacy config: ${legacyPath} -> ${targetPath}`);
|
|
||||||
} catch {
|
|
||||||
// If it already exists, skip silently.
|
|
||||||
}
|
|
||||||
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadAndMaybeMigrateDoctorConfig(params: {
|
export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||||
options: DoctorOptions;
|
options: DoctorOptions;
|
||||||
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const shouldRepair = params.options.repair === true || params.options.yes === true;
|
const shouldRepair = params.options.repair === true || params.options.yes === true;
|
||||||
const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env });
|
const preflight = await runDoctorConfigPreflight();
|
||||||
if (stateDirResult.changes.length > 0) {
|
let snapshot = preflight.snapshot;
|
||||||
note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
|
const baseCfg = preflight.baseConfig;
|
||||||
}
|
|
||||||
if (stateDirResult.warnings.length > 0) {
|
|
||||||
note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacyConfigChanges = await maybeMigrateLegacyConfig();
|
|
||||||
if (legacyConfigChanges.length > 0) {
|
|
||||||
note(legacyConfigChanges.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
|
|
||||||
}
|
|
||||||
|
|
||||||
let snapshot = await readConfigFileSnapshot();
|
|
||||||
const baseCfg = snapshot.config ?? {};
|
|
||||||
let cfg: OpenClawConfig = baseCfg;
|
let cfg: OpenClawConfig = baseCfg;
|
||||||
let candidate = structuredClone(baseCfg);
|
let candidate = structuredClone(baseCfg);
|
||||||
let pendingChanges = false;
|
let pendingChanges = false;
|
||||||
let shouldWriteConfig = false;
|
let shouldWriteConfig = false;
|
||||||
const fixHints: string[] = [];
|
const fixHints: string[] = [];
|
||||||
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
|
|
||||||
note("Config invalid; doctor will run with best-effort config.", "Config");
|
|
||||||
noteIncludeConfinementWarning(snapshot);
|
|
||||||
}
|
|
||||||
const warnings = snapshot.warnings ?? [];
|
|
||||||
if (warnings.length > 0) {
|
|
||||||
const lines = formatConfigIssueLines(warnings, "-").join("\n");
|
|
||||||
note(lines, "Config warnings");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.legacyIssues.length > 0) {
|
if (snapshot.legacyIssues.length > 0) {
|
||||||
note(
|
note(
|
||||||
|
|||||||
109
src/commands/doctor-config-preflight.ts
Normal file
109
src/commands/doctor-config-preflight.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import { readConfigFileSnapshot } from "../config/config.js";
|
||||||
|
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||||
|
import { note } from "../terminal/note.js";
|
||||||
|
import { resolveHomeDir } from "../utils.js";
|
||||||
|
import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js";
|
||||||
|
|
||||||
|
async function maybeMigrateLegacyConfig(): Promise<string[]> {
|
||||||
|
const changes: string[] = [];
|
||||||
|
const home = resolveHomeDir();
|
||||||
|
if (!home) {
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDir = path.join(home, ".openclaw");
|
||||||
|
const targetPath = path.join(targetDir, "openclaw.json");
|
||||||
|
try {
|
||||||
|
await fs.access(targetPath);
|
||||||
|
return changes;
|
||||||
|
} catch {
|
||||||
|
// missing config
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyCandidates = [
|
||||||
|
path.join(home, ".clawdbot", "clawdbot.json"),
|
||||||
|
path.join(home, ".moldbot", "moldbot.json"),
|
||||||
|
path.join(home, ".moltbot", "moltbot.json"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let legacyPath: string | null = null;
|
||||||
|
for (const candidate of legacyCandidates) {
|
||||||
|
try {
|
||||||
|
await fs.access(candidate);
|
||||||
|
legacyPath = candidate;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!legacyPath) {
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(targetDir, { recursive: true });
|
||||||
|
try {
|
||||||
|
await fs.copyFile(legacyPath, targetPath, fs.constants.COPYFILE_EXCL);
|
||||||
|
changes.push(`Migrated legacy config: ${legacyPath} -> ${targetPath}`);
|
||||||
|
} catch {
|
||||||
|
// If it already exists, skip silently.
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DoctorConfigPreflightResult = {
|
||||||
|
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
|
||||||
|
baseConfig: OpenClawConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runDoctorConfigPreflight(
|
||||||
|
options: {
|
||||||
|
migrateState?: boolean;
|
||||||
|
migrateLegacyConfig?: boolean;
|
||||||
|
invalidConfigNote?: string | false;
|
||||||
|
} = {},
|
||||||
|
): Promise<DoctorConfigPreflightResult> {
|
||||||
|
if (options.migrateState !== false) {
|
||||||
|
const { autoMigrateLegacyStateDir } = await import("./doctor-state-migrations.js");
|
||||||
|
const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env });
|
||||||
|
if (stateDirResult.changes.length > 0) {
|
||||||
|
note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
|
||||||
|
}
|
||||||
|
if (stateDirResult.warnings.length > 0) {
|
||||||
|
note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.migrateLegacyConfig !== false) {
|
||||||
|
const legacyConfigChanges = await maybeMigrateLegacyConfig();
|
||||||
|
if (legacyConfigChanges.length > 0) {
|
||||||
|
note(legacyConfigChanges.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await readConfigFileSnapshot();
|
||||||
|
const invalidConfigNote =
|
||||||
|
options.invalidConfigNote ?? "Config invalid; doctor will run with best-effort config.";
|
||||||
|
if (
|
||||||
|
invalidConfigNote &&
|
||||||
|
snapshot.exists &&
|
||||||
|
!snapshot.valid &&
|
||||||
|
snapshot.legacyIssues.length === 0
|
||||||
|
) {
|
||||||
|
note(invalidConfigNote, "Config");
|
||||||
|
noteIncludeConfinementWarning(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings = snapshot.warnings ?? [];
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
note(formatConfigIssueLines(warnings, "-").join("\n"), "Config warnings");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot,
|
||||||
|
baseConfig: snapshot.config ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -21,6 +21,7 @@ describe("legacy root entry", () => {
|
|||||||
it("does not run CLI bootstrap when imported as a library dependency", async () => {
|
it("does not run CLI bootstrap when imported as a library dependency", async () => {
|
||||||
const mod = await import("./index.js");
|
const mod = await import("./index.js");
|
||||||
|
|
||||||
|
expect(typeof mod.applyTemplate).toBe("function");
|
||||||
expect(typeof mod.runLegacyCliEntry).toBe("function");
|
expect(typeof mod.runLegacyCliEntry).toBe("function");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
79
src/index.ts
79
src/index.ts
@ -5,36 +5,38 @@ import { formatUncaughtError } from "./infra/errors.js";
|
|||||||
import { isMainModule } from "./infra/is-main.js";
|
import { isMainModule } from "./infra/is-main.js";
|
||||||
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
|
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
|
||||||
|
|
||||||
const library = await import("./library.js");
|
|
||||||
|
|
||||||
export const assertWebChannel = library.assertWebChannel;
|
|
||||||
export const applyTemplate = library.applyTemplate;
|
|
||||||
export const createDefaultDeps = library.createDefaultDeps;
|
|
||||||
export const deriveSessionKey = library.deriveSessionKey;
|
|
||||||
export const describePortOwner = library.describePortOwner;
|
|
||||||
export const ensureBinary = library.ensureBinary;
|
|
||||||
export const ensurePortAvailable = library.ensurePortAvailable;
|
|
||||||
export const getReplyFromConfig = library.getReplyFromConfig;
|
|
||||||
export const handlePortError = library.handlePortError;
|
|
||||||
export const loadConfig = library.loadConfig;
|
|
||||||
export const loadSessionStore = library.loadSessionStore;
|
|
||||||
export const monitorWebChannel = library.monitorWebChannel;
|
|
||||||
export const normalizeE164 = library.normalizeE164;
|
|
||||||
export const PortInUseError = library.PortInUseError;
|
|
||||||
export const promptYesNo = library.promptYesNo;
|
|
||||||
export const resolveSessionKey = library.resolveSessionKey;
|
|
||||||
export const resolveStorePath = library.resolveStorePath;
|
|
||||||
export const runCommandWithTimeout = library.runCommandWithTimeout;
|
|
||||||
export const runExec = library.runExec;
|
|
||||||
export const saveSessionStore = library.saveSessionStore;
|
|
||||||
export const toWhatsappJid = library.toWhatsappJid;
|
|
||||||
export const waitForever = library.waitForever;
|
|
||||||
|
|
||||||
type LegacyCliDeps = {
|
type LegacyCliDeps = {
|
||||||
installGaxiosFetchCompat: () => Promise<void>;
|
installGaxiosFetchCompat: () => Promise<void>;
|
||||||
runCli: (argv: string[]) => Promise<void>;
|
runCli: (argv: string[]) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LibraryExports = typeof import("./library.js");
|
||||||
|
|
||||||
|
// These bindings are populated only for library consumers. The CLI entry stays
|
||||||
|
// on the lean path and must not read them while running as main.
|
||||||
|
export let assertWebChannel: LibraryExports["assertWebChannel"];
|
||||||
|
export let applyTemplate: LibraryExports["applyTemplate"];
|
||||||
|
export let createDefaultDeps: LibraryExports["createDefaultDeps"];
|
||||||
|
export let deriveSessionKey: LibraryExports["deriveSessionKey"];
|
||||||
|
export let describePortOwner: LibraryExports["describePortOwner"];
|
||||||
|
export let ensureBinary: LibraryExports["ensureBinary"];
|
||||||
|
export let ensurePortAvailable: LibraryExports["ensurePortAvailable"];
|
||||||
|
export let getReplyFromConfig: LibraryExports["getReplyFromConfig"];
|
||||||
|
export let handlePortError: LibraryExports["handlePortError"];
|
||||||
|
export let loadConfig: LibraryExports["loadConfig"];
|
||||||
|
export let loadSessionStore: LibraryExports["loadSessionStore"];
|
||||||
|
export let monitorWebChannel: LibraryExports["monitorWebChannel"];
|
||||||
|
export let normalizeE164: LibraryExports["normalizeE164"];
|
||||||
|
export let PortInUseError: LibraryExports["PortInUseError"];
|
||||||
|
export let promptYesNo: LibraryExports["promptYesNo"];
|
||||||
|
export let resolveSessionKey: LibraryExports["resolveSessionKey"];
|
||||||
|
export let resolveStorePath: LibraryExports["resolveStorePath"];
|
||||||
|
export let runCommandWithTimeout: LibraryExports["runCommandWithTimeout"];
|
||||||
|
export let runExec: LibraryExports["runExec"];
|
||||||
|
export let saveSessionStore: LibraryExports["saveSessionStore"];
|
||||||
|
export let toWhatsappJid: LibraryExports["toWhatsappJid"];
|
||||||
|
export let waitForever: LibraryExports["waitForever"];
|
||||||
|
|
||||||
async function loadLegacyCliDeps(): Promise<LegacyCliDeps> {
|
async function loadLegacyCliDeps(): Promise<LegacyCliDeps> {
|
||||||
const [{ installGaxiosFetchCompat }, { runCli }] = await Promise.all([
|
const [{ installGaxiosFetchCompat }, { runCli }] = await Promise.all([
|
||||||
import("./infra/gaxios-fetch-compat.js"),
|
import("./infra/gaxios-fetch-compat.js"),
|
||||||
@ -57,6 +59,33 @@ const isMain = isMainModule({
|
|||||||
currentFile: fileURLToPath(import.meta.url),
|
currentFile: fileURLToPath(import.meta.url),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!isMain) {
|
||||||
|
({
|
||||||
|
assertWebChannel,
|
||||||
|
applyTemplate,
|
||||||
|
createDefaultDeps,
|
||||||
|
deriveSessionKey,
|
||||||
|
describePortOwner,
|
||||||
|
ensureBinary,
|
||||||
|
ensurePortAvailable,
|
||||||
|
getReplyFromConfig,
|
||||||
|
handlePortError,
|
||||||
|
loadConfig,
|
||||||
|
loadSessionStore,
|
||||||
|
monitorWebChannel,
|
||||||
|
normalizeE164,
|
||||||
|
PortInUseError,
|
||||||
|
promptYesNo,
|
||||||
|
resolveSessionKey,
|
||||||
|
resolveStorePath,
|
||||||
|
runCommandWithTimeout,
|
||||||
|
runExec,
|
||||||
|
saveSessionStore,
|
||||||
|
toWhatsappJid,
|
||||||
|
waitForever,
|
||||||
|
} = await import("./library.js"));
|
||||||
|
}
|
||||||
|
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
|
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
|
||||||
// These log the error and exit gracefully instead of crashing without trace.
|
// These log the error and exit gracefully instead of crashing without trace.
|
||||||
|
|||||||
@ -78,15 +78,15 @@ describe("isMainModule", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to basename matching for relative or symlinked entrypoints", () => {
|
it("returns false for another entrypoint with the same basename", () => {
|
||||||
expect(
|
expect(
|
||||||
isMainModule({
|
isMainModule({
|
||||||
currentFile: "/repo/dist/index.js",
|
currentFile: "/repo/node_modules/openclaw/dist/index.js",
|
||||||
argv: ["node", "../other/index.js"],
|
argv: ["node", "/repo/dist/index.js"],
|
||||||
cwd: "/repo/dist",
|
cwd: "/repo",
|
||||||
env: {},
|
env: {},
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false when no entrypoint candidate exists", () => {
|
it("returns false when no entrypoint candidate exists", () => {
|
||||||
|
|||||||
@ -59,14 +59,5 @@ export function isMainModule({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: basename match (relative paths, symlinked bins).
|
|
||||||
if (
|
|
||||||
normalizedCurrent &&
|
|
||||||
normalizedArgv1 &&
|
|
||||||
path.basename(normalizedCurrent) === path.basename(normalizedArgv1)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user