diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index c62f33a93f4..702fcd6b960 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -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; + }; + 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"); diff --git a/src/agents/auth-profiles/state.ts b/src/agents/auth-profiles/state.ts index 76650ae5418..47b45fd4675 100644 --- a/src/agents/auth-profiles/state.ts +++ b/src/agents/auth-profiles/state.ts @@ -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; } diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index c55408c72d5..396ca710c15 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -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, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 728dd13464b..0a525d2c4c1 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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, diff --git a/src/infra/json-file.ts b/src/infra/json-file.ts index f1cecb19e03..96c9f353b78 100644 --- a/src/infra/json-file.ts +++ b/src/infra/json-file.ts @@ -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(pathname: string): T | undefined { const direct = tryReadJsonSync(pathname);