diff --git a/CHANGELOG.md b/CHANGELOG.md index 233ead3fae9..a009e800259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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. +- 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: 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. diff --git a/src/cli/channel-options.test.ts b/src/cli/channel-options.test.ts index 2333488050b..07786d48af0 100644 --- a/src/cli/channel-options.test.ts +++ b/src/cli/channel-options.test.ts @@ -1,9 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; 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 () => { const actual = await vi.importActual("node:fs"); @@ -22,25 +19,12 @@ vi.mock("../channels/registry.js", () => ({ 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() { return await import("./channel-options.js"); } describe("resolveCliChannelOptions", () => { afterEach(() => { - delete process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS; vi.resetModules(); vi.clearAllMocks(); }); @@ -49,50 +33,26 @@ describe("resolveCliChannelOptions", () => { readFileSyncMock.mockReturnValue( JSON.stringify({ channelOptions: ["cached", "telegram", "cached"] }), ); - listCatalogMock.mockReturnValue([{ id: "catalog-only" }]); const mod = await loadModule(); - expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram", "catalog-only"]); - expect(listCatalogMock).toHaveBeenCalledOnce(); + expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram"]); }); - 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(() => { throw new Error("ENOENT"); }); - listCatalogMock.mockReturnValue([{ id: "feishu" }, { id: "telegram" }]); const mod = await loadModule(); - expect(mod.resolveCliChannelOptions()).toEqual(["telegram", "discord", "feishu"]); - expect(listCatalogMock).toHaveBeenCalledOnce(); + expect(mod.resolveCliChannelOptions()).toEqual(["telegram", "discord"]); }); - it("respects eager mode and includes loaded plugin ids", 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 () => { + it("ignores external catalog env during CLI bootstrap", async () => { process.env.OPENCLAW_PLUGIN_CATALOG_PATHS = "/tmp/plugins-catalog.json"; readFileSyncMock.mockReturnValue(JSON.stringify({ channelOptions: ["cached", "telegram"] })); - listCatalogMock.mockReturnValue([{ id: "custom-catalog" }]); const mod = await loadModule(); - expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram", "custom-catalog"]); - expect(listCatalogMock).toHaveBeenCalledOnce(); + expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram"]); delete process.env.OPENCLAW_PLUGIN_CATALOG_PATHS; }); }); diff --git a/src/cli/channel-options.ts b/src/cli/channel-options.ts index e8562f51516..280d66f56b0 100644 --- a/src/cli/channel-options.ts +++ b/src/cli/channel-options.ts @@ -1,11 +1,7 @@ import fs from "node:fs"; import path from "node:path"; 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 { isTruthyEnvValue } from "../infra/env.js"; -import { ensurePluginRegistryLoaded } from "./plugin-registry.js"; function dedupe(values: string[]): string[] { const seen = new Set(); @@ -48,19 +44,8 @@ function loadPrecomputedChannelOptions(): string[] | null { } 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 catalog = listChannelPluginCatalogEntries().map((entry) => entry.id); - const base = precomputed - ? dedupe([...precomputed, ...catalog]) - : dedupe([...CHAT_CHANNEL_ORDER, ...catalog]); - return base; + return precomputed ?? [...CHAT_CHANNEL_ORDER]; } export function formatCliChannelOptions(extra: string[] = []): string { diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 6ec09d25a6d..acca7967fd6 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -4,8 +4,8 @@ import type { RuntimeEnv } from "../../runtime.js"; const loadAndMaybeMigrateDoctorConfigMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); -vi.mock("../../commands/doctor-config-flow.js", () => ({ - loadAndMaybeMigrateDoctorConfig: loadAndMaybeMigrateDoctorConfigMock, +vi.mock("../../commands/doctor-config-preflight.js", () => ({ + runDoctorConfigPreflight: loadAndMaybeMigrateDoctorConfigMock, })); vi.mock("../../config/config.js", () => ({ @@ -58,12 +58,17 @@ describe("ensureConfigReady", () => { } function setInvalidSnapshot(overrides?: Partial>) { - readConfigFileSnapshotMock.mockResolvedValue({ + const snapshot = { ...makeSnapshot(), exists: true, valid: false, issues: [{ path: "channels.whatsapp", message: "invalid" }], ...overrides, + }; + readConfigFileSnapshotMock.mockResolvedValue(snapshot); + loadAndMaybeMigrateDoctorConfigMock.mockResolvedValue({ + snapshot, + baseConfig: {}, }); } @@ -78,6 +83,10 @@ describe("ensureConfigReady", () => { vi.clearAllMocks(); resetConfigGuardStateForTests(); readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot()); + loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => ({ + snapshot: makeSnapshot(), + baseConfig: {}, + })); }); it.each([ @@ -94,6 +103,13 @@ describe("ensureConfigReady", () => { ])("$name", async ({ commandPath, expectedDoctorCalls }) => { await runEnsureConfigReady(commandPath); 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 () => { @@ -132,6 +148,10 @@ describe("ensureConfigReady", () => { it("prevents preflight stdout noise when suppression is enabled", async () => { loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => { process.stdout.write("Doctor warnings\n"); + return { + snapshot: makeSnapshot(), + baseConfig: {}, + }; }); const output = await withCapturedStdout(async () => { await runEnsureConfigReady(["message"], true); @@ -142,6 +162,10 @@ describe("ensureConfigReady", () => { it("allows preflight stdout noise when suppression is not enabled", async () => { loadAndMaybeMigrateDoctorConfigMock.mockImplementation(async () => { process.stdout.write("Doctor warnings\n"); + return { + snapshot: makeSnapshot(), + baseConfig: {}, + }; }); const output = await withCapturedStdout(async () => { await runEnsureConfigReady(["message"], false); diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index e741b6a42ac..555c555a058 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -39,22 +39,25 @@ export async function ensureConfigReady(params: { suppressDoctorStdout?: boolean; }): Promise { const commandPath = params.commandPath ?? []; + let preflightSnapshot: Awaited> | null = null; if (!didRunDoctorConfigFlow && shouldMigrateStateFromPath(commandPath)) { didRunDoctorConfigFlow = true; - const runDoctorConfigFlow = async () => - (await import("../../commands/doctor-config-flow.js")).loadAndMaybeMigrateDoctorConfig({ - options: { nonInteractive: true }, - confirm: async () => false, + const runDoctorConfigPreflight = async () => + (await import("../../commands/doctor-config-preflight.js")).runDoctorConfigPreflight({ + // Keep ordinary CLI startup on the lightweight validation path. + migrateState: false, + migrateLegacyConfig: false, + invalidConfigNote: false, }); if (!params.suppressDoctorStdout) { - await runDoctorConfigFlow(); + preflightSnapshot = (await runDoctorConfigPreflight()).snapshot; } else { const originalStdoutWrite = process.stdout.write.bind(process.stdout); const originalSuppressNotes = process.env.OPENCLAW_SUPPRESS_NOTES; process.stdout.write = (() => true) as unknown as typeof process.stdout.write; process.env.OPENCLAW_SUPPRESS_NOTES = "1"; try { - await runDoctorConfigFlow(); + preflightSnapshot = (await runDoctorConfigPreflight()).snapshot; } finally { process.stdout.write = originalStdoutWrite; 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 subcommandName = commandPath[1]; const allowInvalid = commandName diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 10721412927..ed82ea4473f 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,5 +1,3 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { fetchTelegramChatId, inspectTelegramAccount, @@ -13,7 +11,7 @@ import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gatewa import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { listRouteBindings } from "../config/bindings.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 { formatConfigIssueLines } from "../config/issue-format.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; @@ -51,17 +49,15 @@ import { isZalouserMutableGroupEntry, } from "../security/mutable-allowlist-detectors.js"; import { note } from "../terminal/note.js"; -import { resolveHomeDir } from "../utils.js"; import { formatConfigPath, - noteIncludeConfinementWarning, noteOpencodeProviderOverrides, resolveConfigPathTarget, stripUnknownConfigKeys, } from "./doctor-config-analysis.js"; +import { runDoctorConfigPreflight } from "./doctor-config-preflight.js"; import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js"; import type { DoctorOptions } from "./doctor-prompter.js"; -import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js"; type TelegramAllowFromUsernameHit = { path: string; entry: string }; @@ -1640,87 +1636,19 @@ function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): { return { config: next, changes }; } -async function maybeMigrateLegacyConfig(): Promise { - 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: { options: DoctorOptions; confirm: (p: { message: string; initialValue: boolean }) => Promise; }) { const shouldRepair = params.options.repair === true || params.options.yes === true; - 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"); - } - - 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 ?? {}; + const preflight = await runDoctorConfigPreflight(); + let snapshot = preflight.snapshot; + const baseCfg = preflight.baseConfig; let cfg: OpenClawConfig = baseCfg; let candidate = structuredClone(baseCfg); let pendingChanges = false; let shouldWriteConfig = false; 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) { note( diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts new file mode 100644 index 00000000000..c41b98e8aa1 --- /dev/null +++ b/src/commands/doctor-config-preflight.ts @@ -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 { + 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>; + baseConfig: OpenClawConfig; +}; + +export async function runDoctorConfigPreflight( + options: { + migrateState?: boolean; + migrateLegacyConfig?: boolean; + invalidConfigNote?: string | false; + } = {}, +): Promise { + 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 ?? {}, + }; +} diff --git a/src/index.test.ts b/src/index.test.ts index 9ad77a02666..013d3d98027 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -21,6 +21,7 @@ describe("legacy root entry", () => { it("does not run CLI bootstrap when imported as a library dependency", async () => { const mod = await import("./index.js"); + expect(typeof mod.applyTemplate).toBe("function"); expect(typeof mod.runLegacyCliEntry).toBe("function"); }); }); diff --git a/src/index.ts b/src/index.ts index 7e901f55a82..f336a9d6b6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,36 +5,38 @@ import { formatUncaughtError } from "./infra/errors.js"; import { isMainModule } from "./infra/is-main.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 = { installGaxiosFetchCompat: () => Promise; runCli: (argv: string[]) => Promise; }; +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 { const [{ installGaxiosFetchCompat }, { runCli }] = await Promise.all([ import("./infra/gaxios-fetch-compat.js"), @@ -57,6 +59,33 @@ const isMain = isMainModule({ 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) { // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. // These log the error and exit gracefully instead of crashing without trace. diff --git a/src/infra/is-main.test.ts b/src/infra/is-main.test.ts index 5fcf3f12076..995b39b8bc8 100644 --- a/src/infra/is-main.test.ts +++ b/src/infra/is-main.test.ts @@ -78,15 +78,15 @@ describe("isMainModule", () => { ).toBe(false); }); - it("falls back to basename matching for relative or symlinked entrypoints", () => { + it("returns false for another entrypoint with the same basename", () => { expect( isMainModule({ - currentFile: "/repo/dist/index.js", - argv: ["node", "../other/index.js"], - cwd: "/repo/dist", + currentFile: "/repo/node_modules/openclaw/dist/index.js", + argv: ["node", "/repo/dist/index.js"], + cwd: "/repo", env: {}, }), - ).toBe(true); + ).toBe(false); }); it("returns false when no entrypoint candidate exists", () => { diff --git a/src/infra/is-main.ts b/src/infra/is-main.ts index be228659eee..e2222ea8093 100644 --- a/src/infra/is-main.ts +++ b/src/infra/is-main.ts @@ -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; }