diff --git a/CHANGELOG.md b/CHANGELOG.md index b3865bb091c..83c6870d157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/src/agents/auth-profiles.store-cache.test.ts b/src/agents/auth-profiles.store-cache.test.ts index 2ab90159953..951ff86008f 100644 --- a/src/agents/auth-profiles.store-cache.test.ts +++ b/src/agents/auth-profiles.store-cache.test.ts @@ -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")]); diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index 033ed192550..9f7bb0ea966 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -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 }); } }); diff --git a/src/agents/auth-profiles/clone.ts b/src/agents/auth-profiles/clone.ts new file mode 100644 index 00000000000..b2115da8c43 --- /dev/null +++ b/src/agents/auth-profiles/clone.ts @@ -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; +} diff --git a/src/agents/auth-profiles/oauth-shared.test.ts b/src/agents/auth-profiles/oauth-shared.test.ts new file mode 100644 index 00000000000..5af1baa69ea --- /dev/null +++ b/src/agents/auth-profiles/oauth-shared.test.ts @@ -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(); + } + }); +}); diff --git a/src/agents/auth-profiles/oauth-shared.ts b/src/agents/auth-profiles/oauth-shared.ts index 964bdddb2ef..9b09ec73dee 100644 --- a/src/agents/auth-profiles/oauth-shared.ts +++ b/src/agents/auth-profiles/oauth-shared.ts @@ -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; } diff --git a/src/agents/auth-profiles/runtime-snapshots.test.ts b/src/agents/auth-profiles/runtime-snapshots.test.ts new file mode 100644 index 00000000000..1a37bc9fb59 --- /dev/null +++ b/src/agents/auth-profiles/runtime-snapshots.test.ts @@ -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(); + } + }); +}); diff --git a/src/agents/auth-profiles/runtime-snapshots.ts b/src/agents/auth-profiles/runtime-snapshots.ts index a7de3d46f86..8c620eb438e 100644 --- a/src/agents/auth-profiles/runtime-snapshots.ts +++ b/src/agents/auth-profiles/runtime-snapshots.ts @@ -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 { diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 8484d62fcfe..c7652ccee70 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -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; diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index f2e9ccc80e8..46b550abc89 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -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(); const cappedKeys = new Set(); const missingKeys = new Set(); @@ -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,