From a0aedea63d6994977974130fc9050ce00971a6fc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 11:24:18 +0100 Subject: [PATCH] fix: guard cli bootstrap imports --- CHANGELOG.md | 1 + docs/help/testing.md | 1 + package.json | 4 +- scripts/build-all.mjs | 8 +- scripts/check-cli-bootstrap-imports.mjs | 127 ++++++++++++++++++ scripts/release-check.ts | 8 ++ src/cli/run-main.exit.test.ts | 83 ++++++++++++ src/cli/run-main.ts | 98 ++++++++++---- src/infra/env.test.ts | 6 +- src/infra/env.ts | 24 +++- test/release-check.test.ts | 2 + .../check-cli-bootstrap-imports.test.ts | 70 ++++++++++ 12 files changed, 393 insertions(+), 39 deletions(-) create mode 100644 scripts/check-cli-bootstrap-imports.mjs create mode 100644 test/scripts/check-cli-bootstrap-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 739ea4821b9..d911a1c7992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Memory-core/dreaming: treat request-scoped narrative fallback as expected, skip session cleanup when no subagent run was created, and remove duplicate phase-level cleanup so fallback no longer emits warning noise. Fixes #67152. Thanks @jsompis. - Agents/exec: apply configured `tools.exec.timeoutSec` to background, `yieldMs`, and node `system.run` commands when no per-call timeout is set, preventing auto-backgrounded and remote node commands from running indefinitely. Fixes #67600; supersedes #67603. Thanks @dlmpx and @kagura-agent. - Config/doctor: stop masking unknown-key validation diagnostics such as `agents.defaults.llm`, and have `openclaw doctor --fix` remove the retired `agents.defaults.llm` timeout block. Thanks @aidiffuser. +- CLI/startup: keep the built pre-dispatch CLI graph free of package-level imports and extend packaged CLI smoke coverage to onboard and doctor help paths, preventing missing runtime dependencies such as tslog from killing onboarding before repair code can run. Fixes #63024. Thanks @hu19940121. - CLI/plugins: preserve unversioned ClawHub install specs so `plugins update` can follow newer ClawHub releases instead of pinning to the initially resolved version. Fixes #63010; supersedes #58426. Thanks @kangsen1234 and @robinspt. - Memory-core/subagents: tag plugin-created subagent sessions with their plugin owner so dreaming narrative cleanup can delete its own ephemeral sessions without granting broad admin session deletion. Fixes #72712. Thanks @BSG2000. - Gateway/models: move local-provider pricing opt-outs, OpenRouter/LiteLLM aliases, and proxy passthrough pricing lookup into plugin manifest metadata so core no longer carries extension-specific pricing tables. Thanks @codex. diff --git a/docs/help/testing.md b/docs/help/testing.md index 4480345a8b9..3f1e7e7f134 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -657,6 +657,7 @@ These Docker runners split into two buckets: explicitly want the larger exhaustive scan. - `test:docker:all` builds the live Docker image once via `test:docker:live-build`, packs OpenClaw once as an npm tarball through `scripts/package-openclaw-for-docker.mjs`, then builds/reuses two `scripts/e2e/Dockerfile` images. The bare image is only the Node/Git runner for install/update/plugin-dependency lanes; those lanes mount the prebuilt tarball. The functional image installs the same tarball into `/app` for built-app functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. If a single lane is heavier than the active caps, the scheduler can still start it when the pool is empty and then keeps it running alone until capacity is available again. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker, or `node scripts/test-docker-all.mjs --plan-json` to print the CI plan for selected lanes, package/image needs, and credentials. - `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. `workflow_ref` selects the trusted workflow/harness scripts, while `package_ref` selects the source commit/branch/tag to pack when `source=ref`; this lets current acceptance logic validate older trusted commits. Profiles are ordered by breadth: `smoke` is quick install/channel/agent plus gateway/config, `package` is the package/update/plugin contract and the default native replacement for most Parallels package/update coverage, `product` adds MCP channels, cron/subagent cleanup, OpenAI web search, and OpenWebUI, and `full` runs the release-path Docker chunks with OpenWebUI. Release validation runs the `package` profile for the target ref with Telegram package QA enabled. Targeted GitHub Docker rerun commands generated from artifacts include prior package artifact and prepared image inputs when available, so failed lanes can avoid rebuilding the package and images. +- Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command. - Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures. - Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:npm-onboard-channel-agent`, `test:docker:update-channel-switch`, `test:docker:session-runtime-context`, `test:docker:agents-delete-shared-workspace`, `test:docker:gateway-network`, `test:docker:browser-cdp-snapshot`, `test:docker:mcp-channels`, `test:docker:pi-bundle-mcp-tools`, `test:docker:cron-mcp-cleanup`, `test:docker:plugins`, `test:docker:plugin-update`, and `test:docker:config-reload` boot one or more real containers and verify higher-level integration paths. diff --git a/package.json b/package.json index 133d08f10d1..aa929f0fe11 100644 --- a/package.json +++ b/package.json @@ -1318,10 +1318,10 @@ "audit:seams": "node scripts/audit-seams.mjs", "build": "node scripts/build-all.mjs", "build:ci-artifacts": "node scripts/build-all.mjs ciArtifacts", - "build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", + "build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --experimental-strip-types scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true", "build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts", - "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs", + "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs", "canon:check": "node scripts/canon.mjs check", "canon:check:json": "node scripts/canon.mjs check --json", "canon:enforce": "node scripts/canon.mjs enforce --json", diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 2b21ca9eadb..75e281ec53c 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -13,6 +13,11 @@ const BUILD_CACHE_VERSION = 2; export const BUILD_ALL_STEPS = [ { label: "canvas:a2ui:bundle", kind: "pnpm", pnpmArgs: ["canvas:a2ui:bundle"] }, { label: "tsdown", kind: "node", args: ["scripts/tsdown-build.mjs"] }, + { + label: "check-cli-bootstrap-imports", + kind: "node", + args: ["scripts/check-cli-bootstrap-imports.mjs"], + }, { label: "runtime-postbuild", kind: "node", args: ["scripts/runtime-postbuild.mjs"] }, { label: "build-stamp", kind: "node", args: ["scripts/build-stamp.mjs"] }, { @@ -91,6 +96,7 @@ export const BUILD_ALL_PROFILES = { ciArtifacts: [ "canvas:a2ui:bundle", "tsdown", + "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp", "build:plugin-sdk:dts", @@ -103,7 +109,7 @@ export const BUILD_ALL_PROFILES = { "write-cli-startup-metadata", "write-cli-compat", ], - gatewayWatch: ["tsdown", "runtime-postbuild", "build-stamp"], + gatewayWatch: ["tsdown", "check-cli-bootstrap-imports", "runtime-postbuild", "build-stamp"], }; export function resolveBuildAllSteps(profile = "full") { diff --git a/scripts/check-cli-bootstrap-imports.mjs b/scripts/check-cli-bootstrap-imports.mjs new file mode 100644 index 00000000000..8404ec0b607 --- /dev/null +++ b/scripts/check-cli-bootstrap-imports.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import module from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_ENTRYPOINTS = ["dist/entry.js", "dist/cli/run-main.js"]; +const STATIC_IMPORT_RE = + /\b(?:import|export)\s+(?:(?:[^'"()]*?\s+from\s+)|)["'](?[^"']+)["']/gu; + +function isMainModule() { + return process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false; +} + +function isBuiltinSpecifier(specifier) { + return specifier.startsWith("node:") || module.isBuiltin(specifier); +} + +function isRelativeSpecifier(specifier) { + return specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/"); +} + +function resolveRelativeImport(importer, specifier, fsImpl = fs) { + const base = specifier.startsWith("/") + ? specifier + : path.resolve(path.dirname(importer), specifier); + const candidates = [ + base, + `${base}.js`, + `${base}.mjs`, + `${base}.cjs`, + path.join(base, "index.js"), + path.join(base, "index.mjs"), + path.join(base, "index.cjs"), + ]; + return candidates.find((candidate) => { + try { + return fsImpl.statSync(candidate).isFile(); + } catch { + return false; + } + }); +} + +export function listStaticImportSpecifiers(source) { + return [...source.matchAll(STATIC_IMPORT_RE)].map((match) => match.groups?.specifier ?? ""); +} + +export function collectCliBootstrapExternalImportErrors(params = {}) { + const rootDir = params.rootDir ?? process.cwd(); + const entrypoints = params.entrypoints ?? DEFAULT_ENTRYPOINTS; + const fsImpl = params.fs ?? fs; + const queue = entrypoints.map((entrypoint) => path.resolve(rootDir, entrypoint)); + const visited = new Set(); + const errors = []; + + for (let index = 0; index < queue.length; index += 1) { + const filePath = queue[index]; + if (!filePath || visited.has(filePath)) { + continue; + } + visited.add(filePath); + + let source; + try { + source = fsImpl.readFileSync(filePath, "utf8"); + } catch { + errors.push( + `CLI bootstrap import guard could not read ${path.relative(rootDir, filePath) || filePath}. Run pnpm build first.`, + ); + continue; + } + + for (const specifier of listStaticImportSpecifiers(source)) { + if (!specifier || isBuiltinSpecifier(specifier)) { + continue; + } + if (!isRelativeSpecifier(specifier)) { + errors.push( + `CLI bootstrap static graph imports external package "${specifier}" from ${path.relative( + rootDir, + filePath, + )}.`, + ); + continue; + } + const resolved = resolveRelativeImport(filePath, specifier, fsImpl); + if (!resolved) { + errors.push( + `CLI bootstrap import guard could not resolve "${specifier}" from ${path.relative( + rootDir, + filePath, + )}.`, + ); + continue; + } + if (!visited.has(resolved)) { + queue.push(resolved); + } + } + } + + return errors.toSorted((left, right) => left.localeCompare(right)); +} + +export function checkCliBootstrapExternalImports(params = {}) { + const errors = collectCliBootstrapExternalImportErrors(params); + if (errors.length === 0) { + return; + } + const logger = params.logger ?? console; + logger.error("CLI bootstrap import guard failed:"); + for (const error of errors) { + logger.error(` - ${error}`); + } + throw new Error("CLI bootstrap static graph imports external packages."); +} + +if (isMainModule()) { + try { + checkCliBootstrapExternalImports(); + console.log("CLI bootstrap import guard passed."); + } catch { + process.exit(1); + } +} diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 7f598ada691..0aa849d6b71 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -23,6 +23,7 @@ import { resolveBundledRuntimeDependencyInstallRoot, resolveBundledRuntimeDependencyPackageInstallRoot, } from "../src/plugins/bundled-runtime-deps.ts"; +import { checkCliBootstrapExternalImports } from "./check-cli-bootstrap-imports.mjs"; import { collectBundledExtensionManifestErrors, type BundledExtension, @@ -110,6 +111,8 @@ const laneFloorAdoptionDateKey = 20260227; const SAFE_UNIX_SMOKE_PATH = "/usr/bin:/bin"; export const PACKED_CLI_SMOKE_COMMANDS = [ ["--help"], + ["onboard", "--help"], + ["doctor", "--help"], ["status", "--json", "--timeout", "1"], ["config", "schema"], ["models", "list", "--provider", "amazon-bedrock"], @@ -807,6 +810,11 @@ async function checkPluginSdkExports() { async function main() { checkAppcastSparkleVersions(); + checkCliBootstrapExternalImports({ + logger: { + error: (message: string) => console.error(`release-check: ${message}`), + }, + }); await checkPluginSdkExports(); checkBundledExtensionMetadata(); await writePackageDistInventory(process.cwd()); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index d34fa63dbf3..21dbac490ab 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -20,6 +20,15 @@ const getProgramContextMock = vi.hoisted(() => vi.fn(() => null)); const registerCoreCliByNameMock = vi.hoisted(() => vi.fn()); const registerSubCliByNameMock = vi.hoisted(() => vi.fn()); const restoreTerminalStateMock = vi.hoisted(() => vi.fn()); +const hasEnvHttpProxyConfiguredMock = vi.hoisted(() => vi.fn(() => false)); +const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn()); +const runCrestodianMock = vi.hoisted(() => vi.fn(async () => {})); +const progressDoneMock = vi.hoisted(() => vi.fn()); +const createCliProgressMock = vi.hoisted(() => + vi.fn(() => ({ + done: progressDoneMock, + })), +); const maybeRunCliInContainerMock = vi.hoisted(() => vi.fn< (argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] } @@ -96,12 +105,29 @@ vi.mock("../terminal/restore.js", () => ({ restoreTerminalState: restoreTerminalStateMock, })); +vi.mock("../infra/net/proxy-env.js", () => ({ + hasEnvHttpProxyConfigured: hasEnvHttpProxyConfiguredMock, +})); + +vi.mock("../infra/net/undici-global-dispatcher.js", () => ({ + ensureGlobalUndiciEnvProxyDispatcher: ensureGlobalUndiciEnvProxyDispatcherMock, +})); + +vi.mock("../crestodian/crestodian.js", () => ({ + runCrestodian: runCrestodianMock, +})); + +vi.mock("./progress.js", () => ({ + createCliProgress: createCliProgressMock, +})); + describe("runCli exit behavior", () => { beforeEach(() => { vi.clearAllMocks(); hasMemoryRuntimeMock.mockReturnValue(false); outputPrecomputedBrowserHelpTextMock.mockReturnValue(false); outputPrecomputedRootHelpTextMock.mockReturnValue(false); + hasEnvHttpProxyConfiguredMock.mockReturnValue(false); getProgramContextMock.mockReturnValue(null); delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH; }); @@ -146,6 +172,17 @@ describe("runCli exit behavior", () => { exitSpy.mockRestore(); }); + it("keeps root help on the precomputed path without proxy bootstrap", async () => { + outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true); + + await runCli(["node", "openclaw", "--help"]); + + expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1); + expect(hasEnvHttpProxyConfiguredMock).not.toHaveBeenCalled(); + expect(ensureGlobalUndiciEnvProxyDispatcherMock).not.toHaveBeenCalled(); + expect(runCrestodianMock).not.toHaveBeenCalled(); + }); + it("renders root help without building the full program", async () => { const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { throw new Error(`unexpected process.exit(${String(code)})`); @@ -163,6 +200,52 @@ describe("runCli exit behavior", () => { exitSpy.mockRestore(); }); + it("bootstraps env proxy before bare Crestodian startup", async () => { + hasEnvHttpProxyConfiguredMock.mockReturnValue(true); + const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + const stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + + try { + await runCli(["node", "openclaw"]); + } finally { + if (stdinTty) { + Object.defineProperty(process.stdin, "isTTY", stdinTty); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + if (stdoutTty) { + Object.defineProperty(process.stdout, "isTTY", stdoutTty); + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } + } + + expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1); + expect(runCrestodianMock).toHaveBeenCalledWith({ onReady: expect.any(Function) }); + expect(ensureGlobalUndiciEnvProxyDispatcherMock.mock.invocationCallOrder[0]).toBeLessThan( + runCrestodianMock.mock.invocationCallOrder[0], + ); + }); + + it("bootstraps env proxy before modern onboard Crestodian startup", async () => { + hasEnvHttpProxyConfiguredMock.mockReturnValue(true); + + await runCli(["node", "openclaw", "onboard", "--modern", "--json"]); + + expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1); + expect(runCrestodianMock).toHaveBeenCalledWith({ + message: undefined, + yes: false, + json: true, + interactive: true, + }); + expect(ensureGlobalUndiciEnvProxyDispatcherMock.mock.invocationCallOrder[0]).toBeLessThan( + runCrestodianMock.mock.invocationCallOrder[0], + ); + }); + it("closes memory managers when a runtime was registered", async () => { tryRouteCliMock.mockResolvedValueOnce(true); hasMemoryRuntimeMock.mockReturnValue(true); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 2c27476fc26..dcb2fc71e89 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -2,24 +2,13 @@ import { existsSync } from "node:fs"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { CommanderError } from "commander"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeEnv } from "../infra/env.js"; -import { formatUncaughtError } from "../infra/errors.js"; import { isMainModule } from "../infra/is-main.js"; -import { ensureGlobalUndiciEnvProxyDispatcher } from "../infra/net/undici-global-dispatcher.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; -import { enableConsoleCapture } from "../logging.js"; import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js"; -import { resolveManifestCommandAliasOwner } from "../plugins/manifest-command-aliases.runtime.js"; -import { hasMemoryRuntime } from "../plugins/memory-state.js"; -import { maybeWarnAboutDebugProxyCoverage } from "../proxy-capture/coverage.js"; -import { - finalizeDebugProxyCapture, - initializeDebugProxyCapture, -} from "../proxy-capture/runtime.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { @@ -28,8 +17,6 @@ import { } from "./command-registration-policy.js"; import { maybeRunCliInContainer, parseCliContainerArgs } from "./container-target.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js"; -import { createCliProgress } from "./progress.js"; -import { tryRouteCli } from "./route.js"; import { resolveMissingPluginCommandMessage as resolveMissingPluginCommandMessageFromPolicy, rewriteUpdateFlagArgv, @@ -51,6 +38,7 @@ export { } from "./run-main-policy.js"; async function closeCliMemoryManagers(): Promise { + const { hasMemoryRuntime } = await import("../plugins/memory-state.js"); if (!hasMemoryRuntime()) { return; } @@ -67,10 +55,11 @@ export function resolveMissingPluginCommandMessage( config?: OpenClawConfig, options?: { registry?: PluginManifestCommandAliasRegistry }, ): string | null { - return resolveMissingPluginCommandMessageFromPolicy(pluginId, config, { - ...(options?.registry ? { registry: options.registry } : {}), - resolveCommandAliasOwner: resolveManifestCommandAliasOwner, - }); + return resolveMissingPluginCommandMessageFromPolicy( + pluginId, + config, + options?.registry ? { registry: options.registry } : undefined, + ); } function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean { @@ -80,6 +69,33 @@ function shouldLoadCliDotEnv(env: NodeJS.ProcessEnv = process.env): boolean { return existsSync(path.join(resolveStateDir(env), ".env")); } +function isCommanderParseExit(error: unknown): error is { exitCode: number } { + if (!error || typeof error !== "object") { + return false; + } + const candidate = error as { code?: unknown; exitCode?: unknown }; + return ( + typeof candidate.exitCode === "number" && + Number.isInteger(candidate.exitCode) && + typeof candidate.code === "string" && + candidate.code.startsWith("commander.") + ); +} + +async function ensureCliEnvProxyDispatcher(): Promise { + try { + const { hasEnvHttpProxyConfigured } = await import("../infra/net/proxy-env.js"); + if (!hasEnvHttpProxyConfigured("https")) { + return; + } + const { ensureGlobalUndiciEnvProxyDispatcher } = + await import("../infra/net/undici-global-dispatcher.js"); + ensureGlobalUndiciEnvProxyDispatcher(); + } catch { + // Best-effort proxy bootstrap; CLI startup should continue without it. + } +} + export async function runCli(argv: string[] = process.argv) { const originalArgv = normalizeWindowsArgv(argv); const parsedContainer = parseCliContainerArgs(originalArgv); @@ -113,12 +129,6 @@ export async function runCli(argv: string[] = process.argv) { loadCliDotEnv({ quiet: true }); } normalizeEnv(); - initializeDebugProxyCapture("cli"); - process.once("exit", () => { - finalizeDebugProxyCapture(); - }); - ensureGlobalUndiciEnvProxyDispatcher(); - maybeWarnAboutDebugProxyCoverage(); if (shouldEnsureCliPath(normalizedArgv)) { ensureOpenClawCliOnPath(); } @@ -143,7 +153,13 @@ export async function runCli(argv: string[] = process.argv) { } } - if (shouldStartCrestodianForBareRoot(normalizedArgv)) { + const shouldRunBareRootCrestodian = shouldStartCrestodianForBareRoot(normalizedArgv); + const shouldRunModernOnboardCrestodian = shouldStartCrestodianForModernOnboard(normalizedArgv); + if (shouldRunBareRootCrestodian || shouldRunModernOnboardCrestodian) { + await ensureCliEnvProxyDispatcher(); + } + + if (shouldRunBareRootCrestodian) { if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error( 'Crestodian needs an interactive TTY. Use `openclaw crestodian --message "status"` for one command.', @@ -152,6 +168,7 @@ export async function runCli(argv: string[] = process.argv) { return; } const { runCrestodian } = await import("../crestodian/crestodian.js"); + const { createCliProgress } = await import("./progress.js"); const progress = createCliProgress({ label: "Starting Crestodian…", indeterminate: true, @@ -174,7 +191,7 @@ export async function runCli(argv: string[] = process.argv) { return; } - if (shouldStartCrestodianForModernOnboard(normalizedArgv)) { + if (shouldRunModernOnboardCrestodian) { const { runCrestodian } = await import("../crestodian/crestodian.js"); const nonInteractive = normalizedArgv.includes("--non-interactive"); await runCrestodian({ @@ -186,10 +203,26 @@ export async function runCli(argv: string[] = process.argv) { return; } + const [ + { initializeDebugProxyCapture, finalizeDebugProxyCapture }, + { maybeWarnAboutDebugProxyCoverage }, + ] = await Promise.all([ + import("../proxy-capture/runtime.js"), + import("../proxy-capture/coverage.js"), + ]); + initializeDebugProxyCapture("cli"); + process.once("exit", () => { + finalizeDebugProxyCapture(); + }); + await ensureCliEnvProxyDispatcher(); + maybeWarnAboutDebugProxyCoverage(); + + const { tryRouteCli } = await import("./route.js"); if (await tryRouteCli(normalizedArgv)) { return; } + const { createCliProgress } = await import("./progress.js"); const startupProgress = createCliProgress({ label: "Loading OpenClaw CLI…", indeterminate: true, @@ -207,15 +240,18 @@ export async function runCli(argv: string[] = process.argv) { try { // Capture all console output into structured logs while keeping stdout/stderr behavior. + const { enableConsoleCapture } = await import("../logging.js"); enableConsoleCapture(); const [ { buildProgram }, + { formatUncaughtError }, { runFatalErrorHooks }, { installUnhandledRejectionHandler, isUncaughtExceptionHandled }, { restoreTerminalState }, ] = await Promise.all([ import("./program.js"), + import("../infra/errors.js"), import("../infra/fatal-error-hooks.js"), import("../infra/unhandled-rejections.js"), import("../terminal/restore.js"), @@ -283,7 +319,15 @@ export async function runCli(argv: string[] = process.argv) { (command) => command.name() === primary || command.aliases().includes(primary), ) ) { - const missingPluginCommandMessage = resolveMissingPluginCommandMessage(primary, config); + const { resolveManifestCommandAliasOwner } = + await import("../plugins/manifest-command-aliases.runtime.js"); + const missingPluginCommandMessage = resolveMissingPluginCommandMessageFromPolicy( + primary, + config, + { + resolveCommandAliasOwner: resolveManifestCommandAliasOwner, + }, + ); if (missingPluginCommandMessage) { throw new Error(missingPluginCommandMessage); } @@ -296,7 +340,7 @@ export async function runCli(argv: string[] = process.argv) { try { await program.parseAsync(parseArgv); } catch (error) { - if (!(error instanceof CommanderError)) { + if (!isCommanderParseExit(error)) { throw error; } process.exitCode = error.exitCode; diff --git a/src/infra/env.test.ts b/src/infra/env.test.ts index e32c090dd6e..8e633a825cd 100644 --- a/src/infra/env.test.ts +++ b/src/infra/env.test.ts @@ -75,7 +75,7 @@ describe("isTruthyEnvValue", () => { }); describe("logAcceptedEnvOption", () => { - it("logs accepted env options once with redaction and formatting", () => { + it("logs accepted env options once with redaction and formatting", async () => { loggerMocks.info.mockClear(); withEnv( @@ -98,7 +98,9 @@ describe("logAcceptedEnvOption", () => { }, ); - expect(loggerMocks.info).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(loggerMocks.info).toHaveBeenCalledTimes(1); + }); expect(loggerMocks.info).toHaveBeenCalledWith( "env: OPENCLAW_TEST_ENV= (test option)", ); diff --git a/src/infra/env.ts b/src/infra/env.ts index 716f67e18ae..5c1e72d8744 100644 --- a/src/infra/env.ts +++ b/src/infra/env.ts @@ -1,12 +1,16 @@ -import { createSubsystemLogger } from "../logging/subsystem.js"; +import type { SubsystemLogger } from "../logging/subsystem.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; -let log: ReturnType | null = null; +let log: SubsystemLogger | null = null; +let logPromise: Promise | null = null; const loggedEnv = new Set(); -function getLog(): ReturnType { +async function getLog(): Promise { if (!log) { - log = createSubsystemLogger("env"); + logPromise ??= import("../logging/subsystem.js").then(({ createSubsystemLogger }) => + createSubsystemLogger("env"), + ); + log = await logPromise; } return log; } @@ -41,9 +45,15 @@ export function logAcceptedEnvOption(option: AcceptedEnvOption): void { return; } loggedEnv.add(option.key); - getLog().info( - `env: ${option.key}=${formatEnvValue(rawValue, option.redact)} (${option.description})`, - ); + void getLog() + .then((logger) => { + logger.info( + `env: ${option.key}=${formatEnvValue(rawValue, option.redact)} (${option.description})`, + ); + }) + .catch(() => { + // Best-effort diagnostics only. + }); } export function normalizeZaiEnv(): void { diff --git a/test/release-check.test.ts b/test/release-check.test.ts index f3fae32e6cf..c94fd22fabc 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -63,6 +63,8 @@ describe("packed CLI smoke", () => { it("keeps the expected packaged CLI smoke command list", () => { expect(PACKED_CLI_SMOKE_COMMANDS).toEqual([ ["--help"], + ["onboard", "--help"], + ["doctor", "--help"], ["status", "--json", "--timeout", "1"], ["config", "schema"], ["models", "list", "--provider", "amazon-bedrock"], diff --git a/test/scripts/check-cli-bootstrap-imports.test.ts b/test/scripts/check-cli-bootstrap-imports.test.ts new file mode 100644 index 00000000000..d611e7c8081 --- /dev/null +++ b/test/scripts/check-cli-bootstrap-imports.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + collectCliBootstrapExternalImportErrors, + listStaticImportSpecifiers, +} from "../../scripts/check-cli-bootstrap-imports.mjs"; + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "openclaw-cli-bootstrap-imports-")); + tempRoots.push(root); + mkdirSync(join(root, "dist", "cli"), { recursive: true }); + return root; +} + +function writeFixture(root: string, relativePath: string, source: string): void { + const target = join(root, relativePath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, source, "utf8"); +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("check-cli-bootstrap-imports", () => { + it("lists only static import and export specifiers", () => { + expect( + listStaticImportSpecifiers(` + import fs from "node:fs"; + import "./side-effect.js"; + export { value } from "../value.js"; + await import("commander"); + `), + ).toEqual(["node:fs", "./side-effect.js", "../value.js"]); + }); + + it("allows a bootstrap graph with builtins and lazy external imports", () => { + const root = makeTempRoot(); + writeFixture( + root, + "dist/entry.js", + `import fs from "node:fs";\nimport "./cli/run-main.js";\nvoid fs;\n`, + ); + writeFixture( + root, + "dist/cli/run-main.js", + `import "../light.js";\nexport async function run() { return import("tslog"); }\n`, + ); + writeFixture(root, "dist/light.js", `import path from "node:path";\nvoid path;\n`); + + expect(collectCliBootstrapExternalImportErrors({ rootDir: root })).toEqual([]); + }); + + it("reports external packages in the static bootstrap graph", () => { + const root = makeTempRoot(); + writeFixture(root, "dist/entry.js", `import "./cli/run-main.js";\n`); + writeFixture(root, "dist/cli/run-main.js", `import "../heavy.js";\n`); + writeFixture(root, "dist/heavy.js", `import { Logger } from "tslog";\nvoid Logger;\n`); + + expect(collectCliBootstrapExternalImportErrors({ rootDir: root })).toEqual([ + 'CLI bootstrap static graph imports external package "tslog" from dist/heavy.js.', + ]); + }); +});