diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index b9d3490b706..10a816d0d1c 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -27,6 +27,10 @@ const configState = vi.hoisted(() => ({ cfg: {} as Record, snapshot: { exists: false } as Record, })); +const readBestEffortConfig = vi.fn(async () => configState.cfg); +const readConfigFileSnapshotWithPluginMetadata = vi.fn(async () => ({ + snapshot: configState.snapshot, +})); const recoverConfigFromLastKnownGood = vi.fn<(params?: unknown) => Promise>( async (_params?: unknown) => false, ); @@ -52,8 +56,9 @@ const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeC vi.mock("../../config/config.js", () => ({ getConfigPath: () => "/tmp/openclaw-test-missing-config.json", - readBestEffortConfig: async () => configState.cfg, + readBestEffortConfig: () => readBestEffortConfig(), readConfigFileSnapshot: async () => configState.snapshot, + readConfigFileSnapshotWithPluginMetadata: () => readConfigFileSnapshotWithPluginMetadata(), recoverConfigFromLastKnownGood: (params: unknown) => recoverConfigFromLastKnownGood(params), recoverConfigFromJsonRootSuffix: (snapshot: unknown) => recoverConfigFromJsonRootSuffix(snapshot), })); @@ -186,6 +191,8 @@ describe("gateway run option collisions", () => { resetRuntimeCapture(); configState.cfg = {}; configState.snapshot = { exists: false }; + readBestEffortConfig.mockClear(); + readConfigFileSnapshotWithPluginMetadata.mockClear(); controlUiState.root = "/tmp/openclaw-control-ui"; gatewayLogMessages.length = 0; recoverConfigFromLastKnownGood.mockReset(); @@ -306,10 +313,15 @@ describe("gateway run option collisions", () => { it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => { await runGatewayCli(["gateway", "run", "--allow-unconfigured"]); + expect(readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1); + expect(readBestEffortConfig).not.toHaveBeenCalled(); expect(startGatewayServer).toHaveBeenCalledWith( 18789, expect.objectContaining({ bind: "loopback", + startupConfigSnapshotRead: { + snapshot: configState.snapshot, + }, }), ); }); @@ -339,7 +351,7 @@ describe("gateway run option collisions", () => { expect(writeDiagnosticStabilityBundleForFailureSync).not.toHaveBeenCalled(); }); - it("blocks startup when the observed snapshot loses gateway.mode even if loadConfig still says local", async () => { + it("blocks startup when the observed snapshot loses gateway.mode", async () => { configState.cfg = { gateway: { mode: "local", @@ -365,6 +377,7 @@ describe("gateway run option collisions", () => { `Config write audit: ${path.join("/tmp", "logs", "config-audit.jsonl")}`, ); expect(startGatewayServer).not.toHaveBeenCalled(); + expect(readBestEffortConfig).not.toHaveBeenCalled(); }); it("restores last-known-good config before startup when the effective config is invalid", async () => { diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index fa4e8303c6e..f62d75f0bbe 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -7,6 +7,7 @@ import type { GatewayAuthMode, GatewayBindMode, GatewayTailscaleMode, + ReadConfigFileSnapshotWithPluginMetadataResult, } from "../../config/config.js"; import { formatConfigIssueSummary } from "../../config/issue-format.js"; import { CONFIG_PATH, resolveGatewayPort, resolveStateDir } from "../../config/paths.js"; @@ -271,18 +272,21 @@ function getGatewayStartGuardErrors(params: { async function readGatewayStartupConfig(params: { startupTrace: ReturnType; -}): Promise<{ cfg: OpenClawConfig; snapshot: ConfigFileSnapshot | null }> { +}): Promise<{ + cfg: OpenClawConfig; + snapshot: ConfigFileSnapshot | null; + startupConfigSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult; +}> { const { - readBestEffortConfig, - readConfigFileSnapshot, + readConfigFileSnapshotWithPluginMetadata, recoverConfigFromLastKnownGood, recoverConfigFromJsonRootSuffix, } = await import("../../config/config.js"); - let cfg = await params.startupTrace.measure("cli.config-load", () => readBestEffortConfig()); - let snapshot: ConfigFileSnapshot | null = await params.startupTrace.measure( - "cli.config-snapshot", - () => readConfigFileSnapshot().catch(() => null), - ); + let snapshotRead: ReadConfigFileSnapshotWithPluginMetadataResult | null = + await params.startupTrace.measure("cli.config-snapshot", () => + readConfigFileSnapshotWithPluginMetadata().catch(() => null), + ); + let snapshot: ConfigFileSnapshot | null = snapshotRead?.snapshot ?? null; if (snapshot?.exists && !snapshot.valid) { const invalidSnapshot = snapshot; const recovered = await params.startupTrace.measure("cli.config-recovery", () => @@ -317,9 +321,10 @@ async function readGatewayStartupConfig(params: { `gateway: failed to persist config auto-recovery notice: ${formatErrorMessage(err)}`, ); } - snapshot = await params.startupTrace.measure("cli.config-snapshot-reload", () => - readConfigFileSnapshot().catch(() => null), + snapshotRead = await params.startupTrace.measure("cli.config-snapshot-reload", () => + readConfigFileSnapshotWithPluginMetadata().catch(() => null), ); + snapshot = snapshotRead?.snapshot ?? null; } else { const repaired = await params.startupTrace.measure("cli.config-prefix-recovery", () => recoverConfigFromJsonRootSuffix(invalidSnapshot), @@ -328,16 +333,19 @@ async function readGatewayStartupConfig(params: { gatewayLog.warn( `gateway: repaired invalid effective config by stripping a non-JSON prefix: ${invalidSnapshot.path}`, ); - snapshot = await params.startupTrace.measure("cli.config-snapshot-reload", () => - readConfigFileSnapshot().catch(() => null), + snapshotRead = await params.startupTrace.measure("cli.config-snapshot-reload", () => + readConfigFileSnapshotWithPluginMetadata().catch(() => null), ); + snapshot = snapshotRead?.snapshot ?? null; } } } - if (snapshot?.valid) { - cfg = snapshot.config; - } - return { cfg, snapshot }; + const cfg = snapshot?.config ?? {}; + return { + cfg, + snapshot, + ...(snapshotRead ? { startupConfigSnapshotRead: snapshotRead } : {}), + }; } function resolveGatewayRunOptions(opts: GatewayRunOpts, command?: Command): GatewayRunOpts { @@ -563,7 +571,9 @@ async function runGatewayCommand(opts: GatewayRunOpts) { } gatewayLog.info("loading configuration…"); - const { cfg, snapshot } = await readGatewayStartupConfig({ startupTrace }); + const { cfg, snapshot, startupConfigSnapshotRead } = await readGatewayStartupConfig({ + startupTrace, + }); void maybeLogPendingControlUiBuild(cfg).catch((err) => { gatewayLog.warn(`Control UI asset check failed: ${String(err)}`); }); @@ -842,6 +852,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) { auth: authOverride, tailscale: tailscaleOverride, startupStartedAt, + ...(startupConfigSnapshotRead ? { startupConfigSnapshotRead } : {}), }), }); diff --git a/src/config/config.ts b/src/config/config.ts index dd5d58988a0..a86eb5d556a 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -39,7 +39,10 @@ export type { ConfigWriteFollowUp, RuntimeConfigSnapshotMetadata, } from "./runtime-snapshot.js"; -export type { ConfigWriteNotification } from "./io.js"; +export type { + ConfigWriteNotification, + ReadConfigFileSnapshotWithPluginMetadataResult, +} from "./io.js"; export { ConfigMutationConflictError, mutateConfigFile, replaceConfigFile } from "./mutate.js"; export * from "./paths.js"; export * from "./recovery-policy.js"; diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index e572f099661..1fba1498b37 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -244,6 +244,42 @@ describe("gateway startup config recovery", () => { expect(log.info).not.toHaveBeenCalled(); }); + it("reuses a CLI preflight snapshot without rereading config", async () => { + const snapshot = buildTestConfigSnapshot({ + path: configPath, + exists: true, + raw: `${JSON.stringify(validConfig)}\n`, + parsed: validConfig, + valid: true, + config: validConfig, + issues: [], + legacyIssues: [], + }); + const log = { info: vi.fn(), warn: vi.fn() }; + + await expect( + loadGatewayStartupConfigSnapshot({ + minimalTestGateway: false, + log, + initialSnapshotRead: { + snapshot, + pluginMetadataSnapshot, + }, + }), + ).resolves.toEqual({ + snapshot, + wroteConfig: false, + pluginMetadataSnapshot, + }); + + expect(configIo.readConfigFileSnapshotWithPluginMetadata).not.toHaveBeenCalled(); + expect(applyPluginAutoEnable).toHaveBeenCalledWith({ + config: validConfig, + env: process.env, + manifestRegistry: pluginManifestRegistry, + }); + }); + it("preserves empty model allowlist entries through startup auto-enable writes", async () => { const sourceConfig = { agents: { diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 96850931f09..50407f3bbaf 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -1,6 +1,7 @@ import { loadAuthProfileStoreWithoutExternalProfiles } from "../agents/auth-profiles.js"; import { formatCliCommand } from "../cli/command-format.js"; import { + type ReadConfigFileSnapshotWithPluginMetadataResult, readConfigFileSnapshotWithPluginMetadata, recoverConfigFromLastKnownGood, recoverConfigFromJsonRootSuffix, @@ -202,11 +203,14 @@ export async function loadGatewayStartupConfigSnapshot(params: { minimalTestGateway: boolean; log: GatewayStartupLog; measure?: GatewayStartupConfigMeasure; + initialSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult; }): Promise { const measure = params.measure ?? (async (_name, run) => await run()); - let snapshotRead = await measure("config.snapshot.read", () => - readConfigFileSnapshotWithPluginMetadata({ measure }), - ); + let snapshotRead = + params.initialSnapshotRead ?? + (await measure("config.snapshot.read", () => + readConfigFileSnapshotWithPluginMetadata({ measure }), + )); let configSnapshot = snapshotRead.snapshot; let pluginMetadataSnapshot = snapshotRead.pluginMetadataSnapshot; let wroteConfig = false; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 86c809f0cfb..f103d2a9b26 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -13,6 +13,8 @@ import { readConfigFileSnapshot, recoverConfigFromLastKnownGood, registerConfigWriteListener, + setRuntimeConfigSnapshot, + type ReadConfigFileSnapshotWithPluginMetadataResult, } from "../config/io.js"; import { replaceConfigFile } from "../config/mutate.js"; import { isNixMode } from "../config/paths.js"; @@ -456,6 +458,11 @@ export type GatewayServerOptions = { * Optional startup timestamp used for concise readiness logging. */ startupStartedAt?: number; + /** + * Config snapshot already read by the CLI gateway preflight. Passing it avoids + * reparsing openclaw.json during server startup. + */ + startupConfigSnapshotRead?: ReadConfigFileSnapshotWithPluginMetadataResult; }; type SetupWizardRunner = NonNullable; @@ -491,6 +498,9 @@ export async function startGatewayServer( minimalTestGateway, log, measure: (name, run) => startupTrace.measure(name, run), + ...(opts.startupConfigSnapshotRead + ? { initialSnapshotRead: opts.startupConfigSnapshotRead } + : {}), }), ); const configSnapshot = startupConfigLoad.snapshot; @@ -585,6 +595,7 @@ export async function startGatewayServer( startupInternalWriteHash = startupSnapshot.hash ?? null; startupLastGoodSnapshot = startupSnapshot; } + setRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig); const pluginBootstrap = await startupTrace.measure("plugins.bootstrap", () => prepareGatewayPluginBootstrap({ cfgAtStart,