From 120c384f0020e1409c46fa93d63d6e8d6c455075 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 13 Apr 2026 19:32:19 +0100 Subject: [PATCH] perf(config): reuse prepared snapshots for daemon token writes --- src/cli/daemon-cli/install.test.ts | 18 ++++++++++++------ src/cli/daemon-cli/install.ts | 8 ++++++-- src/commands/gateway-install-token.ts | 27 +++++++++++++++------------ src/config/io.ts | 7 ++++++- src/config/mutate.test.ts | 23 +++++++++++++++++++++++ src/config/mutate.ts | 8 +++++++- 6 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/cli/daemon-cli/install.test.ts b/src/cli/daemon-cli/install.test.ts index 4e5e3e818a1..c1440a65894 100644 --- a/src/cli/daemon-cli/install.test.ts +++ b/src/cli/daemon-cli/install.test.ts @@ -67,11 +67,15 @@ vi.mock("../../bootstrap/node-startup-env.js", () => ({ vi.mock("../../config/io.js", () => ({ loadConfig: loadConfigMock, - readBestEffortConfig: loadConfigMock, + readConfigFileSnapshotForWrite: vi.fn(async () => ({ + snapshot: await readConfigFileSnapshotMock(), + writeOptions: { expectedConfigPath: "/tmp/openclaw.json" }, + })), })); vi.mock("../../config/paths.js", () => ({ resolveGatewayPort: resolveGatewayPortMock, + resolveIsNixMode: resolveIsNixModeMock, })); vi.mock("../../commands/gateway-install-token.persist.runtime.js", () => ({ @@ -79,10 +83,6 @@ vi.mock("../../commands/gateway-install-token.persist.runtime.js", () => ({ replaceConfigFile: replaceConfigFileMock, })); -vi.mock("../../config/paths.js", () => ({ - resolveIsNixMode: resolveIsNixModeMock, -})); - vi.mock("../../config/types.secrets.js", () => ({ hasConfiguredSecretInput: hasConfiguredSecretInputMock, resolveSecretInputRef: resolveSecretInputRefMock, @@ -190,7 +190,12 @@ describe("runDaemonInstall", () => { actionState.failed.length = 0; loadConfigMock.mockReturnValue({ gateway: { auth: { mode: "token" } } }); - readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} }); + readConfigFileSnapshotMock.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + sourceConfig: { gateway: { auth: { mode: "token" } } }, + }); resolveGatewayPortMock.mockReturnValue(18789); resolveIsNixModeMock.mockReturnValue(false); resolveSecretInputRefMock.mockReturnValue({ ref: undefined }); @@ -274,6 +279,7 @@ describe("runDaemonInstall", () => { exists: true, valid: true, config: { gateway: { auth: { mode: "token" } } }, + sourceConfig: { gateway: { auth: { mode: "token" } } }, }); await runDaemonInstall({ json: true }); diff --git a/src/cli/daemon-cli/install.ts b/src/cli/daemon-cli/install.ts index f5a295d4d8d..ffdea598647 100644 --- a/src/cli/daemon-cli/install.ts +++ b/src/cli/daemon-cli/install.ts @@ -5,7 +5,7 @@ import { isGatewayDaemonRuntime, } from "../../commands/daemon-runtime.js"; import { resolveGatewayInstallToken } from "../../commands/gateway-install-token.js"; -import { readBestEffortConfig } from "../../config/io.js"; +import { readConfigFileSnapshotForWrite } from "../../config/io.js"; import { resolveGatewayPort } from "../../config/paths.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { isNonFatalSystemdInstallProbeError } from "../../daemon/systemd.js"; @@ -38,7 +38,9 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { return; } - const cfg = await readBestEffortConfig(); + const { snapshot: configSnapshot, writeOptions: configWriteOptions } = + await readConfigFileSnapshotForWrite(); + const cfg = configSnapshot.valid ? configSnapshot.sourceConfig : configSnapshot.config; const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { fail("Invalid port"); @@ -104,6 +106,8 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { const tokenResolution = await resolveGatewayInstallToken({ config: cfg, + configSnapshot, + configWriteOptions, env: installEnv, explicitToken: opts.token, autoGenerateWhenMissing: true, diff --git a/src/commands/gateway-install-token.ts b/src/commands/gateway-install-token.ts index 0c66d492bcc..5a2db419cb1 100644 --- a/src/commands/gateway-install-token.ts +++ b/src/commands/gateway-install-token.ts @@ -1,15 +1,23 @@ import { formatCliCommand } from "../cli/command-format.js"; +import type { ConfigWriteOptions } from "../config/io.js"; import type { OpenClawConfig } from "../config/types.js"; +import type { ConfigFileSnapshot } from "../config/types.openclaw.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { shouldRequireGatewayTokenForInstall } from "../gateway/auth-install-policy.js"; import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js"; import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + readConfigFileSnapshot, + replaceConfigFile, +} from "./gateway-install-token.persist.runtime.js"; import { randomToken } from "./random-token.js"; type GatewayInstallTokenOptions = { config: OpenClawConfig; + configSnapshot?: ConfigFileSnapshot; + configWriteOptions?: ConfigWriteOptions; env: NodeJS.ProcessEnv; explicitToken?: string; autoGenerateWhenMissing?: boolean; @@ -23,24 +31,15 @@ export type GatewayInstallTokenResolution = { warnings: string[]; }; -let gatewayInstallTokenPersistRuntimePromise: - | Promise - | undefined; - -async function loadGatewayInstallTokenPersistRuntime() { - gatewayInstallTokenPersistRuntimePromise ??= import("./gateway-install-token.persist.runtime.js"); - return gatewayInstallTokenPersistRuntimePromise; -} - async function maybePersistAutoGeneratedGatewayInstallToken(params: { token: string; config: OpenClawConfig; + configSnapshot?: ConfigFileSnapshot; + configWriteOptions?: ConfigWriteOptions; warnings: string[]; }): Promise { try { - const { readConfigFileSnapshot, replaceConfigFile } = - await loadGatewayInstallTokenPersistRuntime(); - const snapshot = await readConfigFileSnapshot(); + const snapshot = params.configSnapshot ?? (await readConfigFileSnapshot()); if (snapshot.exists && !snapshot.valid) { params.warnings.push( "Warning: config file exists but is invalid; skipping token persistence.", @@ -71,6 +70,8 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: { }, }, }, + snapshot: params.configSnapshot, + writeOptions: params.configWriteOptions, }); return params.token; } @@ -170,6 +171,8 @@ export async function resolveGatewayInstallToken( token = await maybePersistAutoGeneratedGatewayInstallToken({ token, config: cfg, + configSnapshot: options.configSnapshot, + configWriteOptions: options.configWriteOptions, warnings, }); } diff --git a/src/config/io.ts b/src/config/io.ts index 8690ed7ba6d..789009ddeae 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -149,6 +149,11 @@ export type ConfigWriteOptions = { * even if schema/default normalization reintroduces them. */ unsetPaths?: string[][]; + /** + * Internal fast path for callers that already hold a fresh config snapshot. + * Avoids rereading the full config just to prepare an immediate write. + */ + baseSnapshot?: ConfigFileSnapshot; }; export type ReadConfigFileSnapshotForWriteResult = { @@ -1408,7 +1413,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { ): Promise<{ persistedHash: string }> { clearConfigCache(); let persistCandidate: unknown = cfg; - const { snapshot } = await readConfigFileSnapshotInternal(); + const snapshot = options.baseSnapshot ?? (await readConfigFileSnapshotInternal()).snapshot; let envRefMap: Map | null = null; let changedPaths: Set | null = null; if (snapshot.valid && snapshot.exists) { diff --git a/src/config/mutate.test.ts b/src/config/mutate.test.ts index f8d74a690e8..b9b24a09d95 100644 --- a/src/config/mutate.test.ts +++ b/src/config/mutate.test.ts @@ -99,4 +99,27 @@ describe("config mutate helpers", () => { ).rejects.toBeInstanceOf(ConfigMutationConflictError); expect(ioMocks.writeConfigFile).not.toHaveBeenCalled(); }); + + it("reuses a provided snapshot and write options for replace", async () => { + const snapshot = createSnapshot({ + hash: "hash-1", + sourceConfig: { gateway: { auth: { mode: "token" } } }, + }); + + await replaceConfigFile({ + baseHash: snapshot.hash, + nextConfig: { gateway: { auth: { mode: "token", token: "minted" } } }, + snapshot, + writeOptions: { expectedConfigPath: snapshot.path }, + }); + + expect(ioMocks.readConfigFileSnapshotForWrite).not.toHaveBeenCalled(); + expect(ioMocks.writeConfigFile).toHaveBeenCalledWith( + { gateway: { auth: { mode: "token", token: "minted" } } }, + { + baseSnapshot: snapshot, + expectedConfigPath: snapshot.path, + }, + ); + }); }); diff --git a/src/config/mutate.ts b/src/config/mutate.ts index fb029b0bd75..45fba7e5809 100644 --- a/src/config/mutate.ts +++ b/src/config/mutate.ts @@ -38,11 +38,17 @@ function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: stri export async function replaceConfigFile(params: { nextConfig: OpenClawConfig; baseHash?: string; + snapshot?: ConfigFileSnapshot; writeOptions?: ConfigWriteOptions; }): Promise { - const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite(); + const prepared = + params.snapshot && params.writeOptions + ? { snapshot: params.snapshot, writeOptions: params.writeOptions } + : await readConfigFileSnapshotForWrite(); + const { snapshot, writeOptions } = prepared; const previousHash = assertBaseHashMatches(snapshot, params.baseHash); await writeConfigFile(params.nextConfig, { + baseSnapshot: snapshot, ...writeOptions, ...params.writeOptions, });