diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 4eecf5654943..d95088945137 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -801,7 +801,7 @@ function mergeRuntimeExternalProfileReferences(params: { return merged; } -function preserveResolvedSecretBackedCredentials(params: { +export function preserveResolvedSecretBackedCredentials(params: { next: AuthProfileStore; existing: AuthProfileStore; }): AuthProfileStore { diff --git a/src/agents/prepared-model-catalog-worker.integration.test.ts b/src/agents/prepared-model-catalog-worker.integration.test.ts index 413609955776..8060a00383ae 100644 --- a/src/agents/prepared-model-catalog-worker.integration.test.ts +++ b/src/agents/prepared-model-catalog-worker.integration.test.ts @@ -11,6 +11,7 @@ import { clearRuntimeAuthProfileStoreSnapshots, replaceRuntimeAuthProfileStoreSnapshots, } from "./auth-profiles/runtime-snapshots.js"; +import { saveAuthProfileStore } from "./auth-profiles/store.js"; import { encodePluginModelCatalogRelativePath, PLUGIN_MODEL_CATALOG_GENERATED_BY, @@ -35,6 +36,7 @@ const REF_ONLY_API_PROVIDER_ID = `${PROVIDER_ID}-ref-api`; const REF_ONLY_API_ENV = "OPENCLAW_WORKER_REF_ONLY_API_KEY"; const REF_ONLY_TOKEN_PROVIDER_ID = `${PROVIDER_ID}-ref-token`; const REF_ONLY_TOKEN_ENV = "OPENCLAW_WORKER_REF_ONLY_TOKEN"; +const DURABLE_AUTH_PROVIDER_ID = `${PROVIDER_ID}-durable-auth`; const tempDirs = useAutoCleanupTempDirTracker((cleanup) => { afterEach(() => { clearRuntimeAuthProfileStoreSnapshots(); @@ -209,6 +211,111 @@ async function waitForMarker(marker: string): Promise { } describe("prepared model catalog worker boundary", () => { + it("refreshes durable auth profiles added, updated, and removed after startup", async () => { + const fixture = await createStaticSnapshot(0); + const route = { + provider: DURABLE_AUTH_PROVIDER_ID, + id: "durable-model", + name: "Durable model", + api: "openai-completions" as const, + baseUrl: "https://durable-auth.invalid/v1", + }; + const config = { + ...fixture.config, + agents: { + ...fixture.config.agents, + list: [ + { + id: "main", + default: true, + agentDir: fixture.agentDir, + workspace: fixture.workspaceDir, + }, + ], + }, + } satisfies OpenClawConfig; + const owner = Object.freeze({ + ...fixture.snapshot, + config, + modelCatalog: { entries: [route], routeVariants: [route] }, + }); + copyPreparedModelRuntimeAuthState(fixture.snapshot, owner); + const project = async () => + await loadGatewayModelCatalogSnapshot({ + getConfig: () => config, + loadPublishedPreparedModelCatalogOwnerSnapshot: async () => owner, + }); + const projectModels = async () => { + const projected = await project(); + const context = { + getRuntimeConfig: () => config, + loadGatewayModelCatalogSnapshot: async () => projected, + readPreparedGatewayModelCatalogSnapshot: async () => projected, + logGateway: { debug: () => undefined }, + } as unknown as GatewayRequestContext; + return { + projected, + result: await buildModelsListResult({ context, params: { view: "all" } }), + }; + }; + const writeDurableProfile = (key?: string) => + saveAuthProfileStore( + { + version: 1, + profiles: key + ? { + [`${DURABLE_AUTH_PROVIDER_ID}:default`]: { + type: "api_key", + provider: DURABLE_AUTH_PROVIDER_ID, + key, + }, + } + : {}, + }, + fixture.agentDir, + ); + + writeDurableProfile("first-key-not-real"); + const added = await projectModels(); + expect(added).toMatchObject({ + result: { + models: [expect.objectContaining({ id: "durable-model", available: true })], + }, + projected: { + authStore: { + profiles: { + [`${DURABLE_AUTH_PROVIDER_ID}:default`]: expect.objectContaining({ + key: "first-key-not-real", + }), + }, + }, + }, + }); + + writeDurableProfile("second-key-not-real"); + const updated = await project(); + expect(updated).toMatchObject({ + authStore: { + profiles: { + [`${DURABLE_AUTH_PROVIDER_ID}:default`]: expect.objectContaining({ + key: "second-key-not-real", + }), + }, + }, + }); + + writeDurableProfile(); + const removed = await projectModels(); + expect(removed).toMatchObject({ + result: { + models: [expect.objectContaining({ id: "durable-model", available: false })], + }, + }); + expect( + removed.projected.authStore.profiles[`${DURABLE_AUTH_PROVIDER_ID}:default`], + ).toBeUndefined(); + }); + it("makes a post-startup Codex login available to direct models.list", async () => { const codexHome = tempDirs.make("openclaw-models-list-codex-"); const fixture = await createStaticSnapshot(0, { CODEX_HOME: codexHome }); diff --git a/src/agents/prepared-model-catalog.worker.ts b/src/agents/prepared-model-catalog.worker.ts index f03f90cb5e2f..6ffc6e0de59a 100644 --- a/src/agents/prepared-model-catalog.worker.ts +++ b/src/agents/prepared-model-catalog.worker.ts @@ -2,7 +2,10 @@ import { parentPort, workerData } from "node:worker_threads"; import { overlayExternalAuthProfiles } from "./auth-profiles/external-auth.js"; import { replaceRuntimeAuthProfileStoreSnapshots } from "./auth-profiles/runtime-snapshots.js"; -import { ensureAuthProfileStoreWithoutExternalProfiles } from "./auth-profiles/store.js"; +import { + loadAuthProfileStoreWithoutExternalProfiles, + preserveResolvedSecretBackedCredentials, +} from "./auth-profiles/store.js"; import { fingerprintPreparedModelCatalogGeneration, type PreparedModelAuthRefreshWorkerInput, @@ -16,14 +19,15 @@ export async function runPreparedModelCatalogWorkerInput( ): Promise { try { if (value.kind === "auth-refresh") { - replaceRuntimeAuthProfileStoreSnapshots([ - { agentDir: value.agentDir, store: value.authStore }, - ]); - const authStore = ensureAuthProfileStoreWithoutExternalProfiles(value.agentDir, { - allowKeychainPrompt: false, - readOnly: true, - syncExternalCli: false, - ...(value.inheritedAuthDir ? { inheritedAuthDir: value.inheritedAuthDir } : {}), + // Durable profiles may be changed by another CLI process after this generation was built. + // Reload them before adding current external overlays, while retaining only literals whose + // unchanged SecretRefs were materialized by the owning generation. + const authStore = preserveResolvedSecretBackedCredentials({ + next: loadAuthProfileStoreWithoutExternalProfiles(value.agentDir, { + allowKeychainPrompt: false, + ...(value.inheritedAuthDir ? { inheritedAuthDir: value.inheritedAuthDir } : {}), + }), + existing: value.authStore, }); return { status: "ok",