perf(config): use direct writes for gateway token persistence

This commit is contained in:
Vincent Koc
2026-04-13 19:38:44 +01:00
parent 1490e2b1d3
commit 587e72df4d
4 changed files with 52 additions and 32 deletions

View File

@@ -7,7 +7,7 @@ const resolveNodeStartupTlsEnvironmentMock = vi.hoisted(() => vi.fn());
const loadConfigMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const resolveGatewayPortMock = vi.hoisted(() => vi.fn(() => 18789));
const replaceConfigFileMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
const resolveIsNixModeMock = vi.hoisted(() => vi.fn(() => false));
const resolveSecretInputRefMock = vi.hoisted(() =>
vi.fn((): { ref: unknown } => ({ ref: undefined })),
@@ -80,7 +80,11 @@ vi.mock("../../config/paths.js", () => ({
vi.mock("../../commands/gateway-install-token.persist.runtime.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
replaceConfigFile: replaceConfigFileMock,
readConfigFileSnapshotForWrite: vi.fn(async () => ({
snapshot: await readConfigFileSnapshotMock(),
writeOptions: { expectedConfigPath: "/tmp/openclaw.json" },
})),
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../../config/types.secrets.js", () => ({
@@ -172,7 +176,7 @@ describe("runDaemonInstall", () => {
resolveNodeStartupTlsEnvironmentMock.mockReset();
readConfigFileSnapshotMock.mockReset();
resolveGatewayPortMock.mockClear();
replaceConfigFileMock.mockReset();
writeConfigFileMock.mockReset();
resolveIsNixModeMock.mockReset();
resolveSecretInputRefMock.mockReset();
resolveGatewayAuthMock.mockReset();
@@ -251,7 +255,7 @@ describe("runDaemonInstall", () => {
expect(actionState.failed).toEqual([]);
expect(buildGatewayInstallPlanMock).toHaveBeenCalledTimes(1);
expectFirstInstallPlanCallOmitsToken();
expect(replaceConfigFileMock).not.toHaveBeenCalled();
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(
actionState.warnings.some((warning) =>
warning.includes("gateway.auth.token is SecretRef-managed"),
@@ -285,13 +289,11 @@ describe("runDaemonInstall", () => {
await runDaemonInstall({ json: true });
expect(actionState.failed).toEqual([]);
expect(replaceConfigFileMock).toHaveBeenCalledTimes(1);
const writtenConfig = replaceConfigFileMock.mock.calls[0]?.[0] as {
nextConfig?: {
gateway?: { auth?: { token?: string } };
};
expect(writeConfigFileMock).toHaveBeenCalledTimes(1);
const writtenConfig = writeConfigFileMock.mock.calls[0]?.[0] as {
gateway?: { auth?: { token?: string } };
};
expect(writtenConfig.nextConfig?.gateway?.auth?.token).toBe("minted-token");
expect(writtenConfig.gateway?.auth?.token).toBe("minted-token");
expect(buildGatewayInstallPlanMock).toHaveBeenCalledWith(
expect.objectContaining({ port: 18789 }),
);

View File

@@ -1,2 +1,5 @@
export { readConfigFileSnapshot } from "../config/io.js";
export { replaceConfigFile } from "../config/mutate.js";
export {
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
writeConfigFile,
} from "../config/io.js";

View File

@@ -3,7 +3,8 @@ import type { OpenClawConfig } from "../config/types.js";
import { resolveGatewayInstallToken } from "./gateway-install-token.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const replaceConfigFileMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotForWriteMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
const resolveSecretInputRefMock = vi.hoisted(() =>
vi.fn((): { ref: unknown } => ({ ref: undefined })),
);
@@ -30,7 +31,8 @@ const randomTokenMock = vi.hoisted(() => vi.fn(() => "generated-token"));
vi.mock("./gateway-install-token.persist.runtime.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
replaceConfigFile: replaceConfigFileMock,
readConfigFileSnapshotForWrite: readConfigFileSnapshotForWriteMock,
writeConfigFile: writeConfigFileMock,
}));
vi.mock("../config/types.secrets.js", () => ({
@@ -62,6 +64,10 @@ describe("resolveGatewayInstallToken", () => {
beforeEach(() => {
vi.clearAllMocks();
readConfigFileSnapshotMock.mockResolvedValue({ exists: false, valid: true, config: {} });
readConfigFileSnapshotForWriteMock.mockImplementation(async () => ({
snapshot: await readConfigFileSnapshotMock(),
writeOptions: {},
}));
resolveSecretInputRefMock.mockReturnValue({ ref: undefined });
hasConfiguredSecretInputMock.mockImplementation((value: unknown) => {
if (typeof value === "string") {
@@ -152,7 +158,7 @@ describe("resolveGatewayInstallToken", () => {
expect(result.unavailableReason).toContain("gateway.auth.mode is unset");
expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode token");
expect(result.unavailableReason).toContain("openclaw config set gateway.auth.mode password");
expect(replaceConfigFileMock).not.toHaveBeenCalled();
expect(writeConfigFileMock).not.toHaveBeenCalled();
expect(resolveSecretRefValuesMock).not.toHaveBeenCalled();
});
@@ -170,7 +176,7 @@ describe("resolveGatewayInstallToken", () => {
expect(
result.warnings.some((message) => message.includes("without saving to config")),
).toBeTruthy();
expect(replaceConfigFileMock).not.toHaveBeenCalled();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("persists auto-generated token when requested", async () => {
@@ -184,9 +190,8 @@ describe("resolveGatewayInstallToken", () => {
});
expect(result.warnings.some((message) => message.includes("saving to config"))).toBeTruthy();
expect(replaceConfigFileMock).toHaveBeenCalledWith({
baseHash: undefined,
nextConfig: expect.objectContaining({
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
gateway: {
auth: {
mode: "token",
@@ -194,7 +199,8 @@ describe("resolveGatewayInstallToken", () => {
},
},
}),
});
expect.objectContaining({ baseSnapshot: expect.any(Object) }),
);
});
it("drops generated plaintext when config changes to SecretRef before persist", async () => {
@@ -227,7 +233,7 @@ describe("resolveGatewayInstallToken", () => {
expect(
result.warnings.some((message) => message.includes("skipping plaintext token persistence")),
).toBeTruthy();
expect(replaceConfigFileMock).not.toHaveBeenCalled();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("does not auto-generate when inferred mode has password SecretRef configured", async () => {
@@ -254,7 +260,7 @@ describe("resolveGatewayInstallToken", () => {
expect(result.token).toBeUndefined();
expect(result.unavailableReason).toBeUndefined();
expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false);
expect(replaceConfigFileMock).not.toHaveBeenCalled();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("passes the install env through to gateway auth resolution", async () => {
@@ -286,7 +292,7 @@ describe("resolveGatewayInstallToken", () => {
expect(result.token).toBeUndefined();
expect(result.unavailableReason).toBeUndefined();
expect(result.warnings.some((message) => message.includes("Auto-generated"))).toBe(false);
expect(replaceConfigFileMock).not.toHaveBeenCalled();
expect(writeConfigFileMock).not.toHaveBeenCalled();
});
it("skips token SecretRef resolution when token auth is not required", async () => {

View File

@@ -9,8 +9,8 @@ import { resolveGatewayAuthToken } from "../gateway/auth-token-resolution.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
readConfigFileSnapshot,
replaceConfigFile,
readConfigFileSnapshotForWrite,
writeConfigFile,
} from "./gateway-install-token.persist.runtime.js";
import { randomToken } from "./random-token.js";
@@ -39,7 +39,14 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: {
warnings: string[];
}): Promise<string | undefined> {
try {
const snapshot = params.configSnapshot ?? (await readConfigFileSnapshot());
const prepared =
params.configSnapshot && params.configWriteOptions
? {
snapshot: params.configSnapshot,
writeOptions: params.configWriteOptions,
}
: await readConfigFileSnapshotForWrite();
const snapshot = params.configSnapshot ?? prepared.snapshot;
if (snapshot.exists && !snapshot.valid) {
params.warnings.push(
"Warning: config file exists but is invalid; skipping token persistence.",
@@ -57,9 +64,8 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: {
? undefined
: normalizeOptionalString(baseConfig.gateway.auth.token);
if (!existingTokenRef && !baseConfigToken) {
await replaceConfigFile({
baseHash: snapshot.hash,
nextConfig: {
await writeConfigFile(
{
...baseConfig,
gateway: {
...baseConfig.gateway,
@@ -70,9 +76,12 @@ async function maybePersistAutoGeneratedGatewayInstallToken(params: {
},
},
},
snapshot: params.configSnapshot,
writeOptions: params.configWriteOptions,
});
{
baseSnapshot: snapshot,
...prepared.writeOptions,
...params.configWriteOptions,
},
);
return params.token;
}
if (baseConfigToken) {