diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 118f4b51c4e..f6a093d52d8 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -457,7 +457,7 @@ function resolveCanonicalSessionKeyFromSessionId(params: { agentId: params.agentId, }, ); - const store = params.api.runtime.agent.session.loadSessionStore(storePath); + const store = params.api.runtime.agent.session.loadSessionStore(storePath, { clone: false }); let bestMatch: | { sessionKey: string; @@ -546,7 +546,7 @@ function resolveRecallRunChannelContext(params: { agentId: params.agentId, }, ); - const store = params.api.runtime.agent.session.loadSessionStore(storePath); + const store = params.api.runtime.agent.session.loadSessionStore(storePath, { clone: false }); const sessionEntry = resolveSessionStoreEntry({ store, sessionKey: resolvedSessionKey, @@ -1404,7 +1404,7 @@ async function persistPluginStatusLines(params: { agentId ? { agentId } : undefined, ); if (!params.statusLine && !debugLine) { - const store = params.api.runtime.agent.session.loadSessionStore(storePath); + const store = params.api.runtime.agent.session.loadSessionStore(storePath, { clone: false }); const existingEntry = resolveSessionStoreEntry({ store, sessionKey }).existing; const hasActiveMemoryEntry = Array.isArray(existingEntry?.pluginDebugEntries) ? existingEntry.pluginDebugEntries.some((entry) => entry?.pluginId === "active-memory") diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index 9cdf0ceb4c8..feecab85ed2 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "./auth-profiles/types.js"; import { resetCliAuthEpochTestDeps, @@ -358,4 +358,28 @@ describe("resolveCliAuthEpoch", () => { expect(fourth).toBeDefined(); expect(fourth).not.toBe(third); }); + + it("uses non-prompting Codex CLI credential reads for epoch fingerprints", async () => { + const readCodexCliCredentialsCached = vi.fn(() => ({ + type: "oauth" as const, + provider: "openai-codex" as const, + access: "local-access", + refresh: "local-refresh", + expires: 1, + })); + setCliAuthEpochTestDeps({ + readCodexCliCredentialsCached, + loadAuthProfileStoreForRuntime: () => ({ + version: 1, + profiles: {}, + }), + }); + + await resolveCliAuthEpoch({ provider: "codex-cli" }); + + expect(readCodexCliCredentialsCached).toHaveBeenCalledWith({ + ttlMs: 5000, + allowKeychainPrompt: false, + }); + }); }); diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts index bdc7668bc4c..df288e7a3d4 100644 --- a/src/agents/cli-auth-epoch.ts +++ b/src/agents/cli-auth-epoch.ts @@ -126,6 +126,7 @@ function getLocalCliCredentialFingerprint(provider: string): string | undefined case "codex-cli": { const credential = cliAuthEpochDeps.readCodexCliCredentialsCached({ ttlMs: 5000, + allowKeychainPrompt: false, }); return credential ? hashCliAuthEpochPart(encodeCodexCredential(credential)) : undefined; } diff --git a/src/agents/model-auth-label.test.ts b/src/agents/model-auth-label.test.ts index c960c110d0d..3be6dad7b37 100644 --- a/src/agents/model-auth-label.test.ts +++ b/src/agents/model-auth-label.test.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({ resolveAuthProfileDisplayLabel: vi.fn(), resolveUsableCustomProviderApiKey: vi.fn(() => null), resolveEnvApiKey: vi.fn<() => { apiKey: string; source: string } | null>(() => null), - readCodexCliCredentialsCached: vi.fn<() => unknown>(() => null), + readCodexCliCredentialsCached: vi.fn<(options?: unknown) => unknown>(() => null), })); vi.mock("./auth-profiles.js", () => ({ @@ -144,6 +144,10 @@ describe("resolveModelAuthLabel", () => { }); expect(label).toBe("oauth (codex-cli)"); + expect(mocks.readCodexCliCredentialsCached).toHaveBeenCalledWith({ + ttlMs: 5_000, + allowKeychainPrompt: false, + }); }); it("can skip external auth profile overlays for status labels", () => { diff --git a/src/agents/model-auth-label.ts b/src/agents/model-auth-label.ts index ba1991e6734..9628201443f 100644 --- a/src/agents/model-auth-label.ts +++ b/src/agents/model-auth-label.ts @@ -74,7 +74,10 @@ export function resolveModelAuthLabel(params: { return `api-key (${envKey.source})`; } - if (providerKey === "codex" && readCodexCliCredentialsCached({ ttlMs: 5_000 })) { + if ( + providerKey === "codex" && + readCodexCliCredentialsCached({ ttlMs: 5_000, allowKeychainPrompt: false }) + ) { return "oauth (codex-cli)"; } diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 8704a7e92ce..a6489d7be74 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -326,6 +326,10 @@ describe("resolveModelAuthMode", () => { try { expect(resolveModelAuthMode("codex", undefined, { version: 1, profiles: {} })).toBe("oauth"); + expect(readCodexCliCredentialsCached).toHaveBeenCalledWith({ + ttlMs: 5_000, + allowKeychainPrompt: false, + }); } finally { readCodexCliCredentialsCached.mockRestore(); } diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 7c704842bbe..babb80f1c70 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -800,7 +800,7 @@ export function resolveModelAuthMode( if ( normalizeProviderId(resolved) === "codex" && - cliCredentials.readCodexCliCredentialsCached({ ttlMs: 5_000 }) + cliCredentials.readCodexCliCredentialsCached({ ttlMs: 5_000, allowKeychainPrompt: false }) ) { return "oauth"; } diff --git a/src/infra/shell-env.test.ts b/src/infra/shell-env.test.ts index f5b87034dfe..c296cdbc5b5 100644 --- a/src/infra/shell-env.test.ts +++ b/src/infra/shell-env.test.ts @@ -270,6 +270,34 @@ describe("shell env fallback", () => { expect(exec).toHaveBeenCalledTimes(1); }); + it("caches login-shell env probe failures for repeated fallback reads", () => { + resetShellPathCacheForTests(); + const env: NodeJS.ProcessEnv = {}; + const logger = { warn: vi.fn() }; + const exec = vi.fn(() => { + throw new Error("shell unavailable"); + }); + + for (let i = 0; i < 2; i += 1) { + expect( + loadShellEnvFallback({ + enabled: true, + env, + expectedKeys: ["OPENAI_API_KEY"], + exec: exec as unknown as Parameters[0]["exec"], + logger, + }), + ).toMatchObject({ + ok: false, + applied: [], + error: "shell unavailable", + }); + } + + expect(exec).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledTimes(2); + }); + it("tracks last applied keys across success, skip, and failure paths", () => { const successEnv: NodeJS.ProcessEnv = {}; const successExec = vi.fn(() => diff --git a/src/infra/shell-env.ts b/src/infra/shell-env.ts index 9465f5ea8b6..414904d731a 100644 --- a/src/infra/shell-env.ts +++ b/src/infra/shell-env.ts @@ -13,7 +13,10 @@ let lastAppliedKeys: string[] = []; let cachedShellPath: string | null | undefined; let cachedEtcShells: Set | null | undefined; let nextExecCacheId = 1; -const loginShellEnvProbeCache = new Map>(); +const loginShellEnvProbeCache = new Map< + string, + { ok: true; entries: Array<[string, string]> } | { ok: false; error: string } +>(); const execCacheIds = new WeakMap(); function resolveShellExecEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { @@ -181,16 +184,18 @@ function probeLoginShellEnv(params: { }); const cached = loginShellEnvProbeCache.get(cacheKey); if (cached) { - return { ok: true, shellEnv: new Map(cached) }; + return cached.ok ? { ok: true, shellEnv: new Map(cached.entries) } : cached; } try { const stdout = execLoginShellEnvZero({ shell, env: execEnv, exec, timeoutMs }); const shellEnv = parseShellEnv(stdout); - loginShellEnvProbeCache.set(cacheKey, [...shellEnv.entries()]); + loginShellEnvProbeCache.set(cacheKey, { ok: true, entries: [...shellEnv.entries()] }); return { ok: true, shellEnv }; } catch (err) { - return { ok: false, error: formatErrorMessage(err) }; + const result = { ok: false as const, error: formatErrorMessage(err) }; + loginShellEnvProbeCache.set(cacheKey, result); + return result; } } diff --git a/src/plugins/manifest-metadata-scan.ts b/src/plugins/manifest-metadata-scan.ts index 86afadb87c3..68f04049a02 100644 --- a/src/plugins/manifest-metadata-scan.ts +++ b/src/plugins/manifest-metadata-scan.ts @@ -19,6 +19,12 @@ type CandidateDir = { const OPENCLAW_PACKAGE_ROOT = fileURLToPath(new URL("../..", import.meta.url)); const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json"; +let manifestMetadataCache: + | { + key: string; + records: PluginManifestMetadataRecord[]; + } + | undefined; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -106,6 +112,16 @@ function readManifestObject(pluginDir: string): Record | undefi return readJsonObject(path.join(pluginDir, PLUGIN_MANIFEST_FILENAME)); } +function manifestFileFingerprint(pluginDir: string): string { + const manifestPath = path.join(pluginDir, PLUGIN_MANIFEST_FILENAME); + try { + const stat = fs.statSync(manifestPath); + return `${manifestPath}:${stat.mtimeMs}:${stat.size}`; + } catch { + return `${manifestPath}:missing`; + } +} + function listPersistedIndexPluginDirs(env: NodeJS.ProcessEnv, startOrder: number): CandidateDir[] { const index = readJsonObject(path.join(resolveStateDir(env), "plugins", "installs.json")); if (!index || !Array.isArray(index.plugins)) { @@ -167,9 +183,23 @@ export function listOpenClawPluginManifestMetadata( ...listChildPluginDirs(path.join(resolveStateDir(env), "extensions"), 4, order, "global"), ); + const uniqueCandidates = uniqueCandidateDirs(candidates); + const cacheKey = JSON.stringify( + uniqueCandidates.map((candidate) => [ + candidate.pluginDir, + candidate.rank, + candidate.order, + candidate.origin ?? "", + manifestFileFingerprint(candidate.pluginDir), + ]), + ); + if (manifestMetadataCache?.key === cacheKey) { + return manifestMetadataCache.records.slice(); + } + const byManifestId = new Map(); const records: PluginManifestMetadataRecord[] = []; - for (const candidate of uniqueCandidateDirs(candidates)) { + for (const candidate of uniqueCandidates) { const manifest = readManifestObject(candidate.pluginDir); if (!manifest) { continue; @@ -184,5 +214,6 @@ export function listOpenClawPluginManifestMetadata( } records.push({ pluginDir: candidate.pluginDir, manifest, origin: candidate.origin }); } + manifestMetadataCache = { key: cacheKey, records }; return records; } diff --git a/src/plugins/manifest-model-id-normalization.test.ts b/src/plugins/manifest-model-id-normalization.test.ts index 2f53899c633..8ae7fe077ee 100644 --- a/src/plugins/manifest-model-id-normalization.test.ts +++ b/src/plugins/manifest-model-id-normalization.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearCurrentPluginMetadataSnapshot, resolvePluginMetadataControlPlaneFingerprint, @@ -9,6 +9,7 @@ import { } from "./current-plugin-metadata-snapshot.js"; import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; import type { InstalledPluginIndex } from "./installed-plugin-index.js"; +import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js"; import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; @@ -243,4 +244,28 @@ describe("manifest model id normalization", () => { process.env.OPENCLAW_STATE_DIR = stateDirB; expect(normalizeDemoModel()).toBe("charlie/demo-model"); }); + + it("reuses manifest metadata while file fingerprints are unchanged", () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "extensions", "normalizer"); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + writeInstallIndex({ stateDir, pluginDir }); + writeNormalizerManifest({ pluginDir, prefix: "alpha" }); + + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_HOME = undefined; + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1"; + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = undefined; + + const readFileSyncSpy = vi.spyOn(fs, "readFileSync"); + + expect(listOpenClawPluginManifestMetadata(process.env)).toHaveLength(1); + expect(listOpenClawPluginManifestMetadata(process.env)).toHaveLength(1); + + const manifestReads = readFileSyncSpy.mock.calls.filter( + ([filePath]) => String(filePath) === manifestPath, + ); + expect(manifestReads).toHaveLength(1); + readFileSyncSpy.mockRestore(); + }); });