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:
Ayaan Zaidi 2026-03-19 10:34:29 +05:30 committed by GitHub
parent 68bc6effc0
commit d978ace90b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 218 additions and 187 deletions

View File

@ -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.

View File

@ -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<typeof import("node:fs")>("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;
});
});

View File

@ -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<string>();
@ -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 {

View File

@ -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<ReturnType<typeof makeSnapshot>>) {
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);

View File

@ -39,22 +39,25 @@ export async function ensureConfigReady(params: {
suppressDoctorStdout?: boolean;
}): Promise<void> {
const commandPath = params.commandPath ?? [];
let preflightSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>> | 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

View File

@ -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<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: {
options: DoctorOptions;
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
}) {
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(

View 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 ?? {},
};
}

View File

@ -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");
});
});

View File

@ -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<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> {
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.

View File

@ -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", () => {

View File

@ -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;
}