Skip to content

Commit 61fc62a

Browse files
committed
fix(auth): avoid structuredClone for auth profile stores
1 parent d678bcf commit 61fc62a

10 files changed

Lines changed: 182 additions & 11 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai
4141
- 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.
4242
- 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.
4343
- 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.
44+
- 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.
4445
- 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.
4546
- 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.
4647
- Slack/hooks: preserve bot alert attachment text in message-received hook content when command text is blank. Fixes #76035; refs #76036. Thanks @amsminn.

src/agents/auth-profiles.store-cache.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,32 @@ describe("auth profile store cache", () => {
130130
});
131131
});
132132

133+
it("isolates cached auth stores without structuredClone", async () => {
134+
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
135+
await withAgentDirEnv("openclaw-auth-store-isolated-", (agentDir) => {
136+
writeAuthStore(agentDir, "sk-test");
137+
138+
const first = ensureAuthProfileStore(agentDir);
139+
const profile = first.profiles["openai:default"];
140+
if (profile?.type === "api_key") {
141+
profile.key = "sk-mutated";
142+
}
143+
first.profiles["anthropic:default"] = {
144+
type: "api_key",
145+
provider: "anthropic",
146+
key: "sk-added",
147+
};
148+
149+
const second = ensureAuthProfileStore(agentDir);
150+
expect(second.profiles["openai:default"]).toMatchObject({
151+
key: "sk-test",
152+
});
153+
expect(second.profiles["anthropic:default"]).toBeUndefined();
154+
expect(structuredCloneSpy).not.toHaveBeenCalled();
155+
});
156+
structuredCloneSpy.mockRestore();
157+
});
158+
133159
it("keeps runtime-only external auth out of persisted auth-profiles.json files", async () => {
134160
mocks.resolveExternalCliAuthProfiles.mockReturnValue([createRuntimeOnlyOverlay("access-1")]);
135161

src/agents/auth-profiles.store.save.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ vi.mock("./auth-profiles/external-auth.js", () => ({
1919

2020
describe("saveAuthProfileStore", () => {
2121
it("strips plaintext when keyRef/tokenRef are present", async () => {
22+
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
2223
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-"));
2324
try {
2425
const store: AuthProfileStore = {
@@ -68,7 +69,9 @@ describe("saveAuthProfileStore", () => {
6869
});
6970

7071
expect(parsed.profiles["anthropic:default"]?.key).toBe("sk-anthropic-plain");
72+
expect(structuredCloneSpy).not.toHaveBeenCalled();
7173
} finally {
74+
structuredCloneSpy.mockRestore();
7275
await fs.rm(agentDir, { recursive: true, force: true });
7376
}
7477
});

src/agents/auth-profiles/clone.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { AuthProfileStore } from "./types.js";
2+
3+
export function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
4+
return JSON.parse(
5+
JSON.stringify(store, (_key, value: unknown) => {
6+
if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") {
7+
throw new TypeError(`AuthProfileStore contains non-JSON value: ${typeof value}`);
8+
}
9+
return value;
10+
}),
11+
) as AuthProfileStore;
12+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { overlayRuntimeExternalOAuthProfiles } from "./oauth-shared.js";
3+
import type { AuthProfileStore } from "./types.js";
4+
5+
describe("overlayRuntimeExternalOAuthProfiles", () => {
6+
it("isolates runtime OAuth overlays without structuredClone", () => {
7+
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
8+
const store: AuthProfileStore = {
9+
version: 1,
10+
profiles: {
11+
"openai:default": {
12+
type: "api_key",
13+
provider: "openai",
14+
key: "sk-test",
15+
},
16+
},
17+
order: {
18+
openai: ["openai:default"],
19+
},
20+
};
21+
22+
try {
23+
const overlaid = overlayRuntimeExternalOAuthProfiles(store, [
24+
{
25+
profileId: "openai-codex:default",
26+
credential: {
27+
type: "oauth",
28+
provider: "openai-codex",
29+
access: "access-1",
30+
refresh: "refresh-1",
31+
expires: Date.now() + 60_000,
32+
},
33+
},
34+
]);
35+
36+
expect(overlaid.profiles["openai-codex:default"]).toMatchObject({
37+
access: "access-1",
38+
});
39+
expect(store.profiles["openai-codex:default"]).toBeUndefined();
40+
41+
overlaid.profiles["openai:default"].provider = "mutated";
42+
overlaid.order!.openai.push("mutated");
43+
44+
expect(store.profiles["openai:default"]).toMatchObject({
45+
provider: "openai",
46+
});
47+
expect(store.order?.openai).toEqual(["openai:default"]);
48+
expect(structuredCloneSpy).not.toHaveBeenCalled();
49+
} finally {
50+
structuredCloneSpy.mockRestore();
51+
}
52+
});
53+
});

src/agents/auth-profiles/oauth-shared.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cloneAuthProfileStore } from "./clone.js";
12
import { hasUsableOAuthCredential as hasUsableStoredOAuthCredential } from "./credential-state.js";
23
import type { AuthProfileStore, OAuthCredential } from "./types.js";
34

@@ -173,7 +174,7 @@ export function overlayRuntimeExternalOAuthProfiles(
173174
if (externalProfiles.length === 0) {
174175
return store;
175176
}
176-
const next = structuredClone(store);
177+
const next = cloneAuthProfileStore(store);
177178
for (const profile of externalProfiles) {
178179
next.profiles[profile.profileId] = profile.credential;
179180
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
clearRuntimeAuthProfileStoreSnapshots,
4+
getRuntimeAuthProfileStoreSnapshot,
5+
replaceRuntimeAuthProfileStoreSnapshots,
6+
setRuntimeAuthProfileStoreSnapshot,
7+
} from "./runtime-snapshots.js";
8+
import type { AuthProfileStore } from "./types.js";
9+
10+
function createStore(access: string): AuthProfileStore {
11+
return {
12+
version: 1,
13+
profiles: {
14+
"openai-codex:default": {
15+
type: "oauth",
16+
provider: "openai-codex",
17+
access,
18+
refresh: `refresh-${access}`,
19+
expires: Date.now() + 60_000,
20+
accountId: "acct-1",
21+
},
22+
},
23+
order: {
24+
"openai-codex": ["openai-codex:default"],
25+
},
26+
usageStats: {
27+
"openai-codex:default": {
28+
lastUsed: 1,
29+
},
30+
},
31+
};
32+
}
33+
34+
describe("runtime auth profile snapshots", () => {
35+
it("isolates set/get/replace snapshot mutations without structuredClone", () => {
36+
const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone");
37+
const agentDir = "/tmp/openclaw-auth-runtime-snapshot-agent";
38+
try {
39+
const stored = createStore("access-1");
40+
setRuntimeAuthProfileStoreSnapshot(stored, agentDir);
41+
stored.profiles["openai-codex:default"].provider = "mutated";
42+
stored.order!["openai-codex"].push("mutated");
43+
44+
const first = getRuntimeAuthProfileStoreSnapshot(agentDir);
45+
expect(first?.profiles["openai-codex:default"]).toMatchObject({
46+
provider: "openai-codex",
47+
access: "access-1",
48+
});
49+
expect(first?.order?.["openai-codex"]).toEqual(["openai-codex:default"]);
50+
51+
first!.profiles["openai-codex:default"].provider = "mutated-again";
52+
first!.usageStats!["openai-codex:default"].lastUsed = 99;
53+
54+
const second = getRuntimeAuthProfileStoreSnapshot(agentDir);
55+
expect(second?.profiles["openai-codex:default"]).toMatchObject({
56+
provider: "openai-codex",
57+
access: "access-1",
58+
});
59+
expect(second?.usageStats?.["openai-codex:default"]?.lastUsed).toBe(1);
60+
61+
const replacement = createStore("access-2");
62+
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: replacement }]);
63+
const replacementCredential = replacement.profiles["openai-codex:default"];
64+
expect(replacementCredential?.type).toBe("oauth");
65+
if (replacementCredential?.type === "oauth") {
66+
replacementCredential.access = "mutated-replacement";
67+
}
68+
69+
const replaced = getRuntimeAuthProfileStoreSnapshot(agentDir);
70+
expect(replaced?.profiles["openai-codex:default"]).toMatchObject({
71+
access: "access-2",
72+
refresh: "refresh-access-2",
73+
});
74+
expect(structuredCloneSpy).not.toHaveBeenCalled();
75+
} finally {
76+
structuredCloneSpy.mockRestore();
77+
clearRuntimeAuthProfileStoreSnapshots();
78+
}
79+
});
80+
});

src/agents/auth-profiles/runtime-snapshots.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cloneAuthProfileStore } from "./clone.js";
12
import { resolveAuthStorePath } from "./path-resolve.js";
23
import type { AuthProfileStore } from "./types.js";
34

@@ -7,10 +8,6 @@ function resolveRuntimeStoreKey(agentDir?: string): string {
78
return resolveAuthStorePath(agentDir);
89
}
910

10-
function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
11-
return structuredClone(store);
12-
}
13-
1411
export function getRuntimeAuthProfileStoreSnapshot(
1512
agentDir?: string,
1613
): AuthProfileStore | undefined {

src/agents/auth-profiles/store.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util";
33
import type { OpenClawConfig } from "../../config/types.openclaw.js";
44
import { withFileLock } from "../../infra/file-lock.js";
55
import { saveJsonFile } from "../../infra/json-file.js";
6+
import { cloneAuthProfileStore } from "./clone.js";
67
import {
78
AUTH_STORE_LOCK_OPTIONS,
89
AUTH_STORE_VERSION,
@@ -68,10 +69,6 @@ const loadedAuthStoreCache = new Map<
6869
}
6970
>();
7071

71-
function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore {
72-
return structuredClone(store);
73-
}
74-
7572
function isInheritedMainOAuthCredential(params: {
7673
agentDir?: string;
7774
profileId: string;

src/config/sessions/cleanup-service.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
resolveSessionFilePathOptions,
1111
resolveStorePath,
1212
} from "./paths.js";
13+
import { cloneSessionStoreRecord } from "./store-cache.js";
1314
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
1415
import {
1516
capEntryCount,
@@ -151,7 +152,7 @@ async function previewStoreCleanup(params: {
151152
}) {
152153
const maintenance = resolveMaintenanceConfig();
153154
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
154-
const previewStore = structuredClone(beforeStore);
155+
const previewStore = cloneSessionStoreRecord(beforeStore);
155156
const staleKeys = new Set<string>();
156157
const cappedKeys = new Set<string>();
157158
const missingKeys = new Set<string>();
@@ -177,7 +178,7 @@ async function previewStoreCleanup(params: {
177178
cappedKeys.add(key);
178179
},
179180
});
180-
const beforeBudgetStore = structuredClone(previewStore);
181+
const beforeBudgetStore = cloneSessionStoreRecord(previewStore);
181182
const diskBudget = await enforceSessionDiskBudget({
182183
store: previewStore,
183184
storePath: params.target.storePath,

0 commit comments

Comments
 (0)