fix(auth): avoid structuredClone for auth profile stores

This commit is contained in:
Peter Steinberger
2026-05-02 14:23:42 +01:00
parent d678bcfcc7
commit 61fc62ade7
10 changed files with 182 additions and 11 deletions

View File

@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
- Security audit/plugins: ignore plugin install backup, disabled, and dependency debris directories when enumerating installed plugin roots, avoiding false-positive findings for `.openclaw-install-backups` after plugin updates. Fixes #75456.
- Telegram: honor runtime conversation bindings for native slash commands in bound top-level groups, so commands like `/status@bot` route to the active non-`main` session instead of falling back to the default route. Fixes #75405; supersedes #75558. Thanks @ziptbm and @yfge.
- Gateway/tasks: make task registry maintenance use pass-local backing-session lookups and fresh active child-session indexes, avoiding repeated full task snapshots and session-store clones on large stale registries. Fixes #73517 and #75708; supersedes #74406 and #75709. Thanks @Lightningxxl, @glfruit, and @jared-rebel.
- Auth/sessions: JSON-clone auth-profile cache/runtime snapshots and remaining session cleanup previews instead of using `structuredClone`, preserving mutation isolation while avoiding native-memory growth on large stores. Fixes #45438. Thanks @markus-lassfolk.
- Models CLI: restore `openclaw models list --provider <id>` catalog and registry fallback rows for unconfigured providers, so provider-specific verification commands no longer report "No models found." Fixes #75517; supersedes #75615. Thanks @lotsoftick and @koshaji.
- Gateway/macOS: write LaunchAgent services with a canonical system PATH and stop preserving old plist PATH entries, so Volta, asdf, fnm, and pnpm shell paths no longer affect gateway child-process Node resolution. Fixes #75233; supersedes #75246. Thanks @nphyde2.
- Slack/hooks: preserve bot alert attachment text in message-received hook content when command text is blank. Fixes #76035; refs #76036. Thanks @amsminn.

View File

@@ -130,6 +130,32 @@ describe("auth profile store cache", () => {
});
});
it("isolates cached auth stores without structuredClone", async () => {
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
await withAgentDirEnv("openclaw-auth-store-isolated-", (agentDir) => {
writeAuthStore(agentDir, "sk-test");
const first = ensureAuthProfileStore(agentDir);
const profile = first.profiles["openai:default"];
if (profile?.type === "api_key") {
profile.key = "sk-mutated";
}
first.profiles["anthropic:default"] = {
type: "api_key",
provider: "anthropic",
key: "sk-added",
};
const second = ensureAuthProfileStore(agentDir);
expect(second.profiles["openai:default"]).toMatchObject({
key: "sk-test",
});
expect(second.profiles["anthropic:default"]).toBeUndefined();
expect(structuredCloneSpy).not.toHaveBeenCalled();
});
structuredCloneSpy.mockRestore();
});
it("keeps runtime-only external auth out of persisted auth-profiles.json files", async () => {
mocks.resolveExternalCliAuthProfiles.mockReturnValue([createRuntimeOnlyOverlay("access-1")]);

View File

@@ -19,6 +19,7 @@ vi.mock("./auth-profiles/external-auth.js", () => ({
describe("saveAuthProfileStore", () => {
it("strips plaintext when keyRef/tokenRef are present", async () => {
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-"));
try {
const store: AuthProfileStore = {
@@ -68,7 +69,9 @@ describe("saveAuthProfileStore", () => {
});
expect(parsed.profiles["anthropic:default"]?.key).toBe("sk-anthropic-plain");
expect(structuredCloneSpy).not.toHaveBeenCalled();
} finally {
structuredCloneSpy.mockRestore();
await fs.rm(agentDir, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,12 @@
import type { AuthProfileStore } from "./types.js";
export function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
return JSON.parse(
JSON.stringify(store, (_key, value: unknown) => {
if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") {
throw new TypeError(`AuthProfileStore contains non-JSON value: ${typeof value}`);
}
return value;
}),
) as AuthProfileStore;
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it, vi } from "vitest";
import { overlayRuntimeExternalOAuthProfiles } from "./oauth-shared.js";
import type { AuthProfileStore } from "./types.js";
describe("overlayRuntimeExternalOAuthProfiles", () => {
it("isolates runtime OAuth overlays without structuredClone", () => {
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
const store: AuthProfileStore = {
version: 1,
profiles: {
"openai:default": {
type: "api_key",
provider: "openai",
key: "sk-test",
},
},
order: {
openai: ["openai:default"],
},
};
try {
const overlaid = overlayRuntimeExternalOAuthProfiles(store, [
{
profileId: "openai-codex:default",
credential: {
type: "oauth",
provider: "openai-codex",
access: "access-1",
refresh: "refresh-1",
expires: Date.now() + 60_000,
},
},
]);
expect(overlaid.profiles["openai-codex:default"]).toMatchObject({
access: "access-1",
});
expect(store.profiles["openai-codex:default"]).toBeUndefined();
overlaid.profiles["openai:default"].provider = "mutated";
overlaid.order!.openai.push("mutated");
expect(store.profiles["openai:default"]).toMatchObject({
provider: "openai",
});
expect(store.order?.openai).toEqual(["openai:default"]);
expect(structuredCloneSpy).not.toHaveBeenCalled();
} finally {
structuredCloneSpy.mockRestore();
}
});
});

View File

@@ -1,3 +1,4 @@
import { cloneAuthProfileStore } from "./clone.js";
import { hasUsableOAuthCredential as hasUsableStoredOAuthCredential } from "./credential-state.js";
import type { AuthProfileStore, OAuthCredential } from "./types.js";
@@ -173,7 +174,7 @@ export function overlayRuntimeExternalOAuthProfiles(
if (externalProfiles.length === 0) {
return store;
}
const next = structuredClone(store);
const next = cloneAuthProfileStore(store);
for (const profile of externalProfiles) {
next.profiles[profile.profileId] = profile.credential;
}

View File

@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import {
clearRuntimeAuthProfileStoreSnapshots,
getRuntimeAuthProfileStoreSnapshot,
replaceRuntimeAuthProfileStoreSnapshots,
setRuntimeAuthProfileStoreSnapshot,
} from "./runtime-snapshots.js";
import type { AuthProfileStore } from "./types.js";
function createStore(access: string): AuthProfileStore {
return {
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access,
refresh: `refresh-${access}`,
expires: Date.now() + 60_000,
accountId: "acct-1",
},
},
order: {
"openai-codex": ["openai-codex:default"],
},
usageStats: {
"openai-codex:default": {
lastUsed: 1,
},
},
};
}
describe("runtime auth profile snapshots", () => {
it("isolates set/get/replace snapshot mutations without structuredClone", () => {
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
const agentDir = "/tmp/openclaw-auth-runtime-snapshot-agent";
try {
const stored = createStore("access-1");
setRuntimeAuthProfileStoreSnapshot(stored, agentDir);
stored.profiles["openai-codex:default"].provider = "mutated";
stored.order!["openai-codex"].push("mutated");
const first = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(first?.profiles["openai-codex:default"]).toMatchObject({
provider: "openai-codex",
access: "access-1",
});
expect(first?.order?.["openai-codex"]).toEqual(["openai-codex:default"]);
first!.profiles["openai-codex:default"].provider = "mutated-again";
first!.usageStats!["openai-codex:default"].lastUsed = 99;
const second = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(second?.profiles["openai-codex:default"]).toMatchObject({
provider: "openai-codex",
access: "access-1",
});
expect(second?.usageStats?.["openai-codex:default"]?.lastUsed).toBe(1);
const replacement = createStore("access-2");
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: replacement }]);
const replacementCredential = replacement.profiles["openai-codex:default"];
expect(replacementCredential?.type).toBe("oauth");
if (replacementCredential?.type === "oauth") {
replacementCredential.access = "mutated-replacement";
}
const replaced = getRuntimeAuthProfileStoreSnapshot(agentDir);
expect(replaced?.profiles["openai-codex:default"]).toMatchObject({
access: "access-2",
refresh: "refresh-access-2",
});
expect(structuredCloneSpy).not.toHaveBeenCalled();
} finally {
structuredCloneSpy.mockRestore();
clearRuntimeAuthProfileStoreSnapshots();
}
});
});

View File

@@ -1,3 +1,4 @@
import { cloneAuthProfileStore } from "./clone.js";
import { resolveAuthStorePath } from "./path-resolve.js";
import type { AuthProfileStore } from "./types.js";
@@ -7,10 +8,6 @@ function resolveRuntimeStoreKey(agentDir?: string): string {
return resolveAuthStorePath(agentDir);
}
function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
return structuredClone(store);
}
export function getRuntimeAuthProfileStoreSnapshot(
agentDir?: string,
): AuthProfileStore | undefined {

View File

@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withFileLock } from "../../infra/file-lock.js";
import { saveJsonFile } from "../../infra/json-file.js";
import { cloneAuthProfileStore } from "./clone.js";
import {
AUTH_STORE_LOCK_OPTIONS,
AUTH_STORE_VERSION,
@@ -68,10 +69,6 @@ const loadedAuthStoreCache = new Map<
}
>();
function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
return structuredClone(store);
}
function isInheritedMainOAuthCredential(params: {
agentDir?: string;
profileId: string;

View File

@@ -10,6 +10,7 @@ import {
resolveSessionFilePathOptions,
resolveStorePath,
} from "./paths.js";
import { cloneSessionStoreRecord } from "./store-cache.js";
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
import {
capEntryCount,
@@ -151,7 +152,7 @@ async function previewStoreCleanup(params: {
}) {
const maintenance = resolveMaintenanceConfig();
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
const previewStore = structuredClone(beforeStore);
const previewStore = cloneSessionStoreRecord(beforeStore);
const staleKeys = new Set<string>();
const cappedKeys = new Set<string>();
const missingKeys = new Set<string>();
@@ -177,7 +178,7 @@ async function previewStoreCleanup(params: {
cappedKeys.add(key);
},
});
const beforeBudgetStore = structuredClone(previewStore);
const beforeBudgetStore = cloneSessionStoreRecord(previewStore);
const diskBudget = await enforceSessionDiskBudget({
store: previewStore,
storePath: params.target.storePath,