mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:40:44 +00:00
fix(auth): avoid structuredClone for auth profile stores
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
12
src/agents/auth-profiles/clone.ts
Normal file
12
src/agents/auth-profiles/clone.ts
Normal 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;
|
||||
}
|
||||
53
src/agents/auth-profiles/oauth-shared.test.ts
Normal file
53
src/agents/auth-profiles/oauth-shared.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
80
src/agents/auth-profiles/runtime-snapshots.test.ts
Normal file
80
src/agents/auth-profiles/runtime-snapshots.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user