perf(gateway): skip unchanged auth persistence writes

This commit is contained in:
Peter Steinberger
2026-05-30 14:44:37 +01:00
parent 2333d47a1e
commit 41e5acbb6c
5 changed files with 158 additions and 7 deletions

View File

@@ -1160,6 +1160,121 @@ describe("saveAuthProfileStore", () => {
}
});
it("does not rewrite auth secrets when only runtime scheduling state changes", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-state-only-"));
try {
const profileId = "anthropic:default";
const store: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "api_key",
provider: "anthropic",
key: "sk-anthropic-plain",
},
},
usageStats: {
[profileId]: { lastUsed: 1 },
},
};
saveAuthProfileStore(store, agentDir);
const authPath = resolveAuthStorePath(agentDir);
const statePath = resolveAuthStatePath(agentDir);
const oldTimestamp = new Date("2001-01-01T00:00:00.000Z");
await fs.utimes(authPath, oldTimestamp, oldTimestamp);
await fs.utimes(statePath, oldTimestamp, oldTimestamp);
saveAuthProfileStore(
{
...store,
usageStats: {
[profileId]: { lastUsed: 2 },
},
},
agentDir,
);
expect((await fs.stat(authPath)).mtimeMs).toBe(oldTimestamp.getTime());
expect((await fs.stat(statePath)).mtimeMs).toBeGreaterThan(oldTimestamp.getTime());
const authProfiles = JSON.parse(await fs.readFile(authPath, "utf8")) as {
usageStats?: unknown;
};
expect(authProfiles.usageStats).toBeUndefined();
const authState = JSON.parse(await fs.readFile(statePath, "utf8")) as {
usageStats?: Record<string, { lastUsed?: number }>;
};
expect(authState.usageStats?.[profileId]?.lastUsed).toBe(2);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it.runIf(process.platform !== "win32")(
"repairs auth secrets permissions when the payload is unchanged",
async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-permissions-"));
try {
const store: AuthProfileStore = {
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-anthropic-plain",
},
},
};
saveAuthProfileStore(store, agentDir);
const authPath = resolveAuthStorePath(agentDir);
await fs.chmod(authPath, 0o644);
saveAuthProfileStore(store, agentDir);
expect((await fs.stat(authPath)).mode & 0o777).toBe(0o600);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
},
);
it("does not rewrite unchanged runtime scheduling state", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-state-same-"));
try {
const profileId = "anthropic:default";
const store: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "api_key",
provider: "anthropic",
key: "sk-anthropic-plain",
},
},
usageStats: {
[profileId]: { lastUsed: 1 },
},
};
saveAuthProfileStore(store, agentDir);
const statePath = resolveAuthStatePath(agentDir);
const oldTimestamp = new Date("2001-01-01T00:00:00.000Z");
await fs.utimes(statePath, oldTimestamp, oldTimestamp);
saveAuthProfileStore(store, agentDir);
expect((await fs.stat(statePath)).mtimeMs).toBe(oldTimestamp.getTime());
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("does not persist unchanged inherited main OAuth when saving secondary local updates", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-inherited-"));
const stateDir = path.join(root, ".openclaw");

View File

@@ -1,5 +1,6 @@
import fs from "node:fs";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { isDeepStrictEqual } from "node:util";
import { loadJsonFile, repairJsonFilePermissions, saveJsonFile } from "../../infra/json-file.js";
import { asFiniteNumber } from "../../shared/number-coercion.js";
import { isRecord } from "../../shared/record-coerce.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
@@ -213,6 +214,10 @@ export function savePersistedAuthProfileState(
}
return null;
}
saveJsonFile(statePath, payload);
if (isDeepStrictEqual(loadJsonFile(statePath), payload)) {
repairJsonFilePermissions(statePath);
} else {
saveJsonFile(statePath, payload);
}
return payload;
}

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withFileLock } from "../../infra/file-lock.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { loadJsonFile, repairJsonFilePermissions, saveJsonFile } from "../../infra/json-file.js";
import { cloneAuthProfileStore } from "./clone.js";
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js";
import {
@@ -1056,11 +1056,16 @@ export function saveAuthProfileStore(
.map(([profileId]) => profileId),
);
const localStore = buildLocalAuthProfileStoreForSave({ store, agentDir, options });
const existingRaw = loadJsonFile(authPath);
const payload = buildPersistedAuthProfileSecretsStore(localStore, undefined, {
existingRaw: loadJsonFile(authPath),
existingRaw,
runtimeLegacyOAuthSidecarProfileIds,
});
saveJsonFile(authPath, payload);
if (isDeepStrictEqual(existingRaw, payload)) {
repairJsonFilePermissions(authPath);
} else {
saveJsonFile(authPath, payload);
}
savePersistedAuthProfileState(localStore, agentDir);
writeCachedAuthProfileStore({
authPath,

View File

@@ -50,7 +50,7 @@ import { getTotalQueueSize } from "../process/command-queue.js";
import type { RuntimeEnv } from "../runtime.js";
import {
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
getActiveSecretsRuntimeConfigSnapshot,
} from "../secrets/runtime-state.js";
import { uniqueStrings } from "../shared/string-normalization.js";
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
@@ -779,7 +779,8 @@ export async function startGatewayServer(
const getResolvedAuth = () =>
resolveGatewayAuth({
authConfig:
getActiveSecretsRuntimeSnapshot()?.config.gateway?.auth ?? getRuntimeConfig().gateway?.auth,
getActiveSecretsRuntimeConfigSnapshot()?.config.gateway?.auth ??
getRuntimeConfig().gateway?.auth,
authOverride: opts.auth,
env: process.env,
tailscaleMode,

View File

@@ -36,6 +36,31 @@ export function saveJsonFile(pathname: string, data: unknown): void {
writeJsonSync(resolveJsonSaveTarget(pathname), data);
}
export function repairJsonFilePermissions(pathname: string): void {
const target = resolveJsonSaveTarget(pathname);
let fd: number | undefined;
try {
fd = fs.openSync(
target,
fs.constants.O_RDONLY |
(process.platform !== "win32" && "O_NOFOLLOW" in fs.constants
? fs.constants.O_NOFOLLOW
: 0),
);
fs.fchmodSync(fd, 0o600);
} catch {
// Matches fs-safe JSON writes: permission repair is best-effort.
} finally {
if (fd !== undefined) {
try {
fs.closeSync(fd);
} catch {
// best-effort cleanup
}
}
}
}
// oxlint-disable-next-line typescript-eslint/no-unnecessary-type-parameters -- legacy typed JSON loader alias.
export function loadJsonFile<T = unknown>(pathname: string): T | undefined {
const direct = tryReadJsonSync<T>(pathname);