perf(config): reuse prepared snapshots for daemon token writes

This commit is contained in:
Vincent Koc
2026-04-13 19:32:19 +01:00
parent b75ad800a5
commit 120c384f00
6 changed files with 69 additions and 22 deletions

View File

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

View File

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

View File

@@ -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<typeof import("./gateway-install-token.persist.runtime.js")>
| 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<string | undefined> {
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,
});
}

View File

@@ -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<string, string> | null = null;
let changedPaths: Set<string> | null = null;
if (snapshot.valid && snapshot.exists) {

View File

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

View File

@@ -38,11 +38,17 @@ function assertBaseHashMatches(snapshot: ConfigFileSnapshot, expectedHash?: stri
export async function replaceConfigFile(params: {
nextConfig: OpenClawConfig;
baseHash?: string;
snapshot?: ConfigFileSnapshot;
writeOptions?: ConfigWriteOptions;
}): Promise<ConfigReplaceResult> {
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,
});