diff --git a/src/agents/auth-profiles/runtime-snapshots.ts b/src/agents/auth-profiles/runtime-snapshots.ts new file mode 100644 index 00000000000..2cf56e5c790 --- /dev/null +++ b/src/agents/auth-profiles/runtime-snapshots.ts @@ -0,0 +1,58 @@ +import { resolveAuthStorePath } from "./paths.js"; +import type { AuthProfileStore } from "./types.js"; + +const runtimeAuthStoreSnapshots = new Map(); + +function resolveRuntimeStoreKey(agentDir?: string): string { + return resolveAuthStorePath(agentDir); +} + +function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore { + return structuredClone(store); +} + +export function getRuntimeAuthProfileStoreSnapshot( + agentDir?: string, +): AuthProfileStore | undefined { + const store = runtimeAuthStoreSnapshots.get(resolveRuntimeStoreKey(agentDir)); + return store ? cloneAuthProfileStore(store) : undefined; +} + +export function hasRuntimeAuthProfileStoreSnapshot(agentDir?: string): boolean { + return runtimeAuthStoreSnapshots.has(resolveRuntimeStoreKey(agentDir)); +} + +export function hasAnyRuntimeAuthProfileStoreSource(agentDir?: string): boolean { + const requestedStore = getRuntimeAuthProfileStoreSnapshot(agentDir); + if (requestedStore && Object.keys(requestedStore.profiles).length > 0) { + return true; + } + if (!agentDir) { + return false; + } + const mainStore = getRuntimeAuthProfileStoreSnapshot(); + return Boolean(mainStore && Object.keys(mainStore.profiles).length > 0); +} + +export function replaceRuntimeAuthProfileStoreSnapshots( + entries: Array<{ agentDir?: string; store: AuthProfileStore }>, +): void { + runtimeAuthStoreSnapshots.clear(); + for (const entry of entries) { + runtimeAuthStoreSnapshots.set( + resolveRuntimeStoreKey(entry.agentDir), + cloneAuthProfileStore(entry.store), + ); + } +} + +export function clearRuntimeAuthProfileStoreSnapshots(): void { + runtimeAuthStoreSnapshots.clear(); +} + +export function setRuntimeAuthProfileStoreSnapshot( + store: AuthProfileStore, + agentDir?: string, +): void { + runtimeAuthStoreSnapshots.set(resolveRuntimeStoreKey(agentDir), cloneAuthProfileStore(store)); +} diff --git a/src/agents/auth-profiles/source-check.ts b/src/agents/auth-profiles/source-check.ts new file mode 100644 index 00000000000..472f772243b --- /dev/null +++ b/src/agents/auth-profiles/source-check.ts @@ -0,0 +1,27 @@ +import fs from "node:fs"; +import { resolveAuthStatePath, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js"; +import { hasAnyRuntimeAuthProfileStoreSource } from "./runtime-snapshots.js"; + +function hasStoredAuthProfileFiles(agentDir?: string): boolean { + return ( + fs.existsSync(resolveAuthStorePath(agentDir)) || + fs.existsSync(resolveAuthStatePath(agentDir)) || + fs.existsSync(resolveLegacyAuthStorePath(agentDir)) + ); +} + +export function hasAnyAuthProfileStoreSource(agentDir?: string): boolean { + if (hasAnyRuntimeAuthProfileStoreSource(agentDir)) { + return true; + } + if (hasStoredAuthProfileFiles(agentDir)) { + return true; + } + + const authPath = resolveAuthStorePath(agentDir); + const mainAuthPath = resolveAuthStorePath(); + if (agentDir && authPath !== mainAuthPath && hasStoredAuthProfileFiles(undefined)) { + return true; + } + return false; +} diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 6c2b6a05b23..c939de13468 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -23,6 +23,13 @@ import { mergeAuthProfileStores, mergeOAuthFileIntoStore, } from "./persisted.js"; +import { + clearRuntimeAuthProfileStoreSnapshots as clearRuntimeAuthProfileStoreSnapshotsImpl, + getRuntimeAuthProfileStoreSnapshot, + hasRuntimeAuthProfileStoreSnapshot, + replaceRuntimeAuthProfileStoreSnapshots as replaceRuntimeAuthProfileStoreSnapshotsImpl, + setRuntimeAuthProfileStoreSnapshot, +} from "./runtime-snapshots.js"; import { savePersistedAuthProfileState } from "./state.js"; import type { AuthProfileStore } from "./types.js"; @@ -37,7 +44,6 @@ type SaveAuthProfileStoreOptions = { syncExternalCli?: boolean; }; -const runtimeAuthStoreSnapshots = new Map(); const loadedAuthStoreCache = new Map< string, { @@ -48,72 +54,36 @@ const loadedAuthStoreCache = new Map< } >(); -function resolveRuntimeStoreKey(agentDir?: string): string { - return resolveAuthStorePath(agentDir); -} - function cloneAuthProfileStore(store: AuthProfileStore): AuthProfileStore { return structuredClone(store); } function resolveRuntimeAuthProfileStore(agentDir?: string): AuthProfileStore | null { - if (runtimeAuthStoreSnapshots.size === 0) { - return null; - } - - const mainKey = resolveRuntimeStoreKey(undefined); - const requestedKey = resolveRuntimeStoreKey(agentDir); - const mainStore = runtimeAuthStoreSnapshots.get(mainKey); - const requestedStore = runtimeAuthStoreSnapshots.get(requestedKey); + const mainKey = resolveAuthStorePath(undefined); + const requestedKey = resolveAuthStorePath(agentDir); + const mainStore = getRuntimeAuthProfileStoreSnapshot(undefined); + const requestedStore = getRuntimeAuthProfileStoreSnapshot(agentDir); if (!agentDir || requestedKey === mainKey) { if (!mainStore) { return null; } - return cloneAuthProfileStore(mainStore); + return mainStore; } if (mainStore && requestedStore) { - return mergeAuthProfileStores( - cloneAuthProfileStore(mainStore), - cloneAuthProfileStore(requestedStore), - ); + return mergeAuthProfileStores(mainStore, requestedStore); } if (requestedStore) { - return cloneAuthProfileStore(requestedStore); + return requestedStore; } if (mainStore) { - return cloneAuthProfileStore(mainStore); + return mainStore; } return null; } -function hasStoredAuthProfileFiles(agentDir?: string): boolean { - return ( - fs.existsSync(resolveAuthStorePath(agentDir)) || - fs.existsSync(resolveAuthStatePath(agentDir)) || - fs.existsSync(resolveLegacyAuthStorePath(agentDir)) - ); -} - -export function replaceRuntimeAuthProfileStoreSnapshots( - entries: Array<{ agentDir?: string; store: AuthProfileStore }>, -): void { - runtimeAuthStoreSnapshots.clear(); - for (const entry of entries) { - runtimeAuthStoreSnapshots.set( - resolveRuntimeStoreKey(entry.agentDir), - cloneAuthProfileStore(entry.store), - ); - } -} - -export function clearRuntimeAuthProfileStoreSnapshots(): void { - runtimeAuthStoreSnapshots.clear(); - loadedAuthStoreCache.clear(); -} - function readAuthStoreMtimeMs(authPath: string): number | null { try { return fs.statSync(authPath).mtimeMs; @@ -387,23 +357,17 @@ export function ensureAuthProfileStoreForLocalUpdate(agentDir?: string): AuthPro return mergeAuthProfileStores(mainStore, store); } -export function hasAnyAuthProfileStoreSource(agentDir?: string): boolean { - const runtimeStore = resolveRuntimeAuthProfileStore(agentDir); - if (runtimeStore && Object.keys(runtimeStore.profiles).length > 0) { - return true; - } +export { hasAnyAuthProfileStoreSource } from "./source-check.js"; - if (hasStoredAuthProfileFiles(agentDir)) { - return true; - } +export function replaceRuntimeAuthProfileStoreSnapshots( + entries: Array<{ agentDir?: string; store: AuthProfileStore }>, +): void { + replaceRuntimeAuthProfileStoreSnapshotsImpl(entries); +} - const authPath = resolveAuthStorePath(agentDir); - const mainAuthPath = resolveAuthStorePath(); - if (agentDir && authPath !== mainAuthPath && hasStoredAuthProfileFiles(undefined)) { - return true; - } - - return false; +export function clearRuntimeAuthProfileStoreSnapshots(): void { + clearRuntimeAuthProfileStoreSnapshotsImpl(); + loadedAuthStoreCache.clear(); } export function saveAuthProfileStore( @@ -413,7 +377,6 @@ export function saveAuthProfileStore( ): void { const authPath = resolveAuthStorePath(agentDir); const statePath = resolveAuthStatePath(agentDir); - const runtimeKey = resolveRuntimeStoreKey(agentDir); const payload = buildPersistedAuthProfileSecretsStore(store, ({ profileId, credential }) => { if (credential.type !== "oauth") { return true; @@ -440,7 +403,7 @@ export function saveAuthProfileStore( stateMtimeMs: readAuthStoreMtimeMs(statePath), store: runtimeStore, }); - if (runtimeAuthStoreSnapshots.has(runtimeKey)) { - runtimeAuthStoreSnapshots.set(runtimeKey, cloneAuthProfileStore(runtimeStore)); + if (hasRuntimeAuthProfileStoreSnapshot(agentDir)) { + setRuntimeAuthProfileStoreSnapshot(runtimeStore, agentDir); } } diff --git a/src/cron/isolated-agent/run-auth-profile.runtime.ts b/src/cron/isolated-agent/run-auth-profile.runtime.ts new file mode 100644 index 00000000000..76945f0619c --- /dev/null +++ b/src/cron/isolated-agent/run-auth-profile.runtime.ts @@ -0,0 +1 @@ +export { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; diff --git a/src/cron/isolated-agent/run.auth-profile-cold-path.test.ts b/src/cron/isolated-agent/run.auth-profile-cold-path.test.ts new file mode 100644 index 00000000000..0b0d197b636 --- /dev/null +++ b/src/cron/isolated-agent/run.auth-profile-cold-path.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const hasAnyAuthProfileStoreSourceMock = vi.fn(() => false); + +vi.mock("../../agents/auth-profiles/source-check.js", () => ({ + hasAnyAuthProfileStoreSource: hasAnyAuthProfileStoreSourceMock, +})); + +import { + clearFastTestEnv, + loadRunCronIsolatedAgentTurn, + resolveSessionAuthProfileOverrideMock, + resetRunCronIsolatedAgentTurnHarness, + restoreFastTestEnv, +} from "./run.test-harness.js"; + +const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); + +function makeParams(overrides?: Record) { + return { + cfg: {}, + deps: {} as never, + job: { + id: "cron-auth-cold-path", + name: "Auth Cold Path", + schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC" }, + sessionTarget: "isolated", + payload: { kind: "agentTurn", message: "run task" }, + }, + message: "run task", + sessionKey: "cron:auth-cold-path", + ...overrides, + }; +} + +describe("runCronIsolatedAgentTurn auth-profile cold path", () => { + let previousFastTestEnv: string | undefined; + + beforeEach(() => { + previousFastTestEnv = clearFastTestEnv(); + resetRunCronIsolatedAgentTurnHarness(); + hasAnyAuthProfileStoreSourceMock.mockReset(); + hasAnyAuthProfileStoreSourceMock.mockReturnValue(false); + }); + + afterEach(() => { + restoreFastTestEnv(previousFastTestEnv); + }); + + it("skips auth-profile override resolution when no sources exist", async () => { + const result = await runCronIsolatedAgentTurn(makeParams()); + + expect(result.status).toBe("ok"); + expect(hasAnyAuthProfileStoreSourceMock).toHaveBeenCalledTimes(1); + expect(resolveSessionAuthProfileOverrideMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cron/isolated-agent/run.runtime.ts b/src/cron/isolated-agent/run.runtime.ts index 65d07692b2d..c6a3a77711d 100644 --- a/src/cron/isolated-agent/run.runtime.ts +++ b/src/cron/isolated-agent/run.runtime.ts @@ -6,7 +6,6 @@ export { resolveDefaultAgentId, resolveAgentSkillsFilter, } from "../../agents/agent-scope.js"; -export { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js"; export { setCliSessionId } from "../../agents/cli-session.js"; export { lookupContextTokens } from "../../agents/context.js"; export { resolveCronStyleNow } from "../../agents/current-time.js"; diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 826b9c22e37..43baab862f9 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -98,7 +98,6 @@ vi.mock("./run.runtime.js", () => ({ resolveAgentWorkspaceDir: vi.fn().mockReturnValue("/tmp/workspace"), resolveDefaultAgentId: vi.fn().mockReturnValue("default"), resolveAgentSkillsFilter: resolveAgentSkillsFilterMock, - resolveSessionAuthProfileOverride: resolveSessionAuthProfileOverrideMock, lookupContextTokens: lookupContextTokensMock, resolveCronStyleNow: resolveCronStyleNowMock, DEFAULT_CONTEXT_TOKENS: 128000, @@ -153,6 +152,10 @@ vi.mock("./run-execution.runtime.js", () => ({ logWarn: (...args: unknown[]) => logWarnMock(...args), })); +vi.mock("./run-auth-profile.runtime.js", () => ({ + resolveSessionAuthProfileOverride: resolveSessionAuthProfileOverrideMock, +})); + vi.mock("../../agents/cli-runner.runtime.js", () => ({ setCliSessionId: vi.fn(), })); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 934b9848252..c671c4c71f7 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -1,3 +1,4 @@ +import { hasAnyAuthProfileStoreSource } from "../../agents/auth-profiles/source-check.js"; import type { SkillSnapshot } from "../../agents/skills.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { CliDeps } from "../../cli/outbound-send-deps.js"; @@ -50,7 +51,6 @@ import { resolveDefaultAgentId, resolveHookExternalContentSource, resolveSessionTranscriptPath, - resolveSessionAuthProfileOverride, resolveThinkingDefault, setSessionRuntimeModel, supportsXHighThinking, @@ -63,12 +63,27 @@ import { resolveCronSkillsSnapshot } from "./skills-snapshot.js"; let sessionStoreRuntimePromise: | Promise | undefined; +let cronAuthProfileRuntimePromise: + | Promise + | undefined; async function loadSessionStoreRuntime() { sessionStoreRuntimePromise ??= import("../../config/sessions/store.runtime.js"); return await sessionStoreRuntimePromise; } +async function loadCronAuthProfileRuntime() { + cronAuthProfileRuntimePromise ??= import("./run-auth-profile.runtime.js"); + return await cronAuthProfileRuntimePromise; +} + +function hasConfiguredAuthProfiles(cfg: OpenClawConfig): boolean { + return ( + Boolean(cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) || + Boolean(cfg.auth?.order && Object.keys(cfg.auth.order).length > 0) + ); +} + function resolveNonNegativeNumber(value: number | undefined): number | undefined { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; } @@ -413,16 +428,26 @@ async function prepareCronRunContext(params: { } catch (err) { logWarn(`[cron:${input.job.id}] Failed to persist pre-run session entry: ${String(err)}`); } - const authProfileId = await resolveSessionAuthProfileOverride({ - cfg: cfgWithAgentDefaults, - provider, - agentDir, - sessionEntry: cronSession.sessionEntry, - sessionStore: cronSession.store, - sessionKey: agentSessionKey, - storePath: cronSession.storePath, - isNewSession: cronSession.isNewSession && input.job.sessionTarget !== "isolated", - }); + const hasSessionAuthProfileOverride = Boolean( + cronSession.sessionEntry.authProfileOverride?.trim(), + ); + const authProfileId = + !hasSessionAuthProfileOverride && + !hasConfiguredAuthProfiles(cfgWithAgentDefaults) && + !hasAnyAuthProfileStoreSource(agentDir) + ? undefined + : await ( + await loadCronAuthProfileRuntime() + ).resolveSessionAuthProfileOverride({ + cfg: cfgWithAgentDefaults, + provider, + agentDir, + sessionEntry: cronSession.sessionEntry, + sessionStore: cronSession.store, + sessionKey: agentSessionKey, + storePath: cronSession.storePath, + isNewSession: cronSession.isNewSession && input.job.sessionTarget !== "isolated", + }); const liveSelection: CronLiveSelection = { provider, model,