fix(models): unify full catalog auth discovery

This commit is contained in:
joshavant
2026-08-12 14:55:23 -05:00
parent 33623fd6f1
commit 3864270f60
9 changed files with 186 additions and 62 deletions
@@ -19,6 +19,7 @@ import {
} from "./plugin-model-catalog.js";
import {
createPreparedModelCatalogWorkerInput,
getPreparedModelFullCatalogAuth,
runPreparedModelCatalogWorker,
} from "./prepared-model-catalog-worker.js";
import { copyPreparedModelRuntimeAuthState } from "./prepared-model-runtime-auth.js";
@@ -37,6 +38,7 @@ 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 DURABLE_AUTH_KEY = "post-startup-durable-key-not-real";
const tempDirs = useAutoCleanupTempDirTracker((cleanup) => {
afterEach(() => {
clearRuntimeAuthProfileStoreSnapshots();
@@ -68,6 +70,7 @@ module.exports = {
run(context) {
const refOnlyApi = context.resolveProviderApiKey(${JSON.stringify(REF_ONLY_API_PROVIDER_ID)}).apiKey;
const refOnlyToken = context.resolveProviderApiKey(${JSON.stringify(REF_ONLY_TOKEN_PROVIDER_ID)}).apiKey;
const durableAuth = context.resolveProviderApiKey(${JSON.stringify(DURABLE_AUTH_PROVIDER_ID)}).apiKey;
const hasRefOnlyApi = refOnlyApi === ${JSON.stringify(REF_ONLY_API_ENV)} || refOnlyApi === process.env[${JSON.stringify(REF_ONLY_API_ENV)}];
const hasRefOnlyToken = refOnlyToken === ${JSON.stringify(REF_ONLY_TOKEN_ENV)} || refOnlyToken === process.env[${JSON.stringify(REF_ONLY_TOKEN_ENV)}];
return { provider: {
@@ -79,6 +82,9 @@ module.exports = {
id: \`ref-proof-api-\${hasRefOnlyApi}-token-\${hasRefOnlyToken}\`,
name: "Ref-only worker proof",
},
...(durableAuth === ${JSON.stringify(DURABLE_AUTH_KEY)}
? [{ id: "post-startup-auth-model", name: "Post-startup auth model" }]
: []),
],
} };
},
@@ -211,6 +217,42 @@ async function waitForMarker(marker: string): Promise<void> {
}
describe("prepared model catalog worker boundary", () => {
it("refreshes durable auth before provider hooks decide catalog membership", async () => {
const fixture = await createStaticSnapshot(0);
saveAuthProfileStore(
{
version: 1,
profiles: {
[`${DURABLE_AUTH_PROVIDER_ID}:default`]: {
type: "api_key",
provider: DURABLE_AUTH_PROVIDER_ID,
key: DURABLE_AUTH_KEY,
},
},
},
fixture.agentDir,
);
const catalog = await fixture.snapshot.loadFullModelCatalog?.();
expect(catalog?.entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
provider: PROVIDER_ID,
id: "post-startup-auth-model",
}),
]),
);
expect(getPreparedModelFullCatalogAuth(catalog!)).toMatchObject({
authStore: {
profiles: {
[`${DURABLE_AUTH_PROVIDER_ID}:default`]: expect.objectContaining({
key: DURABLE_AUTH_KEY,
}),
},
},
});
});
it("refreshes durable auth profiles added, updated, and removed after startup", async () => {
const fixture = await createStaticSnapshot(0);
const route = {
+26 -9
View File
@@ -4,6 +4,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";
import { resolveInstalledManifestRegistryIndexFingerprint } from "../plugins/manifest-registry-installed.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import type { PreparedAgentCredentialModes } from "./agent-auth-credential-modes.js";
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
import { PreparedModelRuntimePublicationSupersededError } from "./prepared-model-runtime.errors.js";
@@ -13,14 +14,12 @@ import {
type PreparedModelRuntimeAgentFacts,
} from "./prepared-model-runtime.facts.js";
import type { PreparedModelRuntimeInput } from "./prepared-model-runtime.types.js";
import type { AuthStorageData } from "./sessions/auth-storage.js";
export type PreparedModelCatalogWorkerInput = Readonly<{
kind: "catalog";
generationFingerprint: string;
input: PreparedModelRuntimeInput;
authStore: AuthProfileStore;
credentials: Readonly<AuthStorageData>;
providerIds: readonly string[];
}>;
@@ -41,6 +40,8 @@ export type PreparedModelWorkerResult =
kind: "catalog";
generationFingerprint: string;
snapshot: ModelCatalogSnapshot;
authStore: AuthProfileStore;
authModes: PreparedAgentCredentialModes;
}>
| Readonly<{
status: "ok";
@@ -50,6 +51,22 @@ export type PreparedModelWorkerResult =
}>
| Readonly<{ status: "failed"; error: string }>;
const authByFullCatalog = new WeakMap<
object,
Readonly<{ authStore: AuthProfileStore; authModes: PreparedAgentCredentialModes }>
>();
export function setPreparedModelFullCatalogAuth(
modelCatalog: object,
auth: Readonly<{ authStore: AuthProfileStore; authModes: PreparedAgentCredentialModes }>,
): void {
authByFullCatalog.set(modelCatalog, auth);
}
export function getPreparedModelFullCatalogAuth(modelCatalog: object) {
return authByFullCatalog.get(modelCatalog);
}
// Cold source/plugin loading can take well over a minute. Three minutes preserves exact full-view
// discovery while bounding a wedged provider; expiry rejects and never returns partial results.
const PREPARED_MODEL_CATALOG_WORKER_TIMEOUT_MS = 180_000;
@@ -68,14 +85,12 @@ function fingerprintPreparedModelCatalogPlugins(snapshot: PluginMetadataSnapshot
export function fingerprintPreparedModelCatalogGeneration(params: {
input: PreparedModelRuntimeInput;
authStore: AuthProfileStore;
credentials: Readonly<AuthStorageData>;
providerIds: readonly string[];
pluginMetadataSnapshot: PluginMetadataSnapshot;
}): string {
return fingerprintPreparedRuntimeFacts({
input: params.input,
authStore: params.authStore,
credentials: params.credentials,
providerIds: params.providerIds,
pluginFingerprint: fingerprintPreparedModelCatalogPlugins(params.pluginMetadataSnapshot),
});
@@ -110,7 +125,7 @@ export function createPreparedModelCatalogWorkerInput(params: {
const input: PreparedModelRuntimeInput = {
...(source.agentId ? { agentId: source.agentId } : {}),
agentDir: source.agentDir,
inheritedAuthDir: source.agentDir,
inheritedAuthDir: source.inheritedAuthDir ?? source.agentDir,
...(source.workspaceDir ? { workspaceDir: source.workspaceDir } : {}),
...(source.readOnly ? { readOnly: true } : {}),
skipCredentials: true,
@@ -122,20 +137,17 @@ export function createPreparedModelCatalogWorkerInput(params: {
config: source.config,
};
const authStore = projectWorkerAuthStore(params.agentFacts.authStore);
const credentials = { ...params.agentFacts.credentials };
const providerIds = [...params.agentFacts.providerIds];
return {
kind: "catalog",
generationFingerprint: fingerprintPreparedModelCatalogGeneration({
input,
authStore,
credentials,
providerIds,
pluginMetadataSnapshot: params.pluginMetadataSnapshot,
}),
input,
authStore,
credentials,
providerIds,
};
}
@@ -304,7 +316,12 @@ export function runPreparedModelCatalogWorker(params: {
if (message.kind !== "catalog") {
throw new Error("prepared model catalog worker returned an auth refresh result");
}
return markPreparedModelCatalogFull(message.snapshot);
const modelCatalog = markPreparedModelCatalogFull(message.snapshot);
setPreparedModelFullCatalogAuth(modelCatalog, {
authStore: message.authStore,
authModes: message.authModes,
});
return modelCatalog;
},
});
}
+3 -1
View File
@@ -57,6 +57,7 @@ vi.mock("./prepared-model-runtime.scoped-catalog.js", () => ({
prepareScopedReadOnlyModelCatalog: (...args: unknown[]) => mocks.prepareScopedCatalog(...args),
}));
import { setPreparedModelFullCatalogAuth } from "./prepared-model-catalog-worker.js";
import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalog.errors.js";
import {
getPublishedPreparedModelCatalogOwnerSnapshot,
@@ -198,8 +199,9 @@ describe("prepared model catalog access", () => {
entries: [{ provider: "test", id: "discovered", name: "Discovered" }],
routeVariants: [],
};
const loadFullModelCatalog = vi.fn(async () => discoveredCatalog);
const { authStore, ...snapshotFacts } = fullSnapshot;
setPreparedModelFullCatalogAuth(discoveredCatalog, { authStore, authModes: {} });
const loadFullModelCatalog = vi.fn(async () => discoveredCatalog);
const snapshot = {
...snapshotFacts,
modelCatalog: configuredCatalog,
+21 -5
View File
@@ -11,9 +11,14 @@ import {
import { resolveLegacyInheritedAuthDir } from "./legacy-inherited-auth-dir.js";
import type { ModelCatalogEntry, ModelCatalogSnapshot } from "./model-catalog.types.js";
import { resolvePublishedModelCatalogOwner } from "./prepared-model-catalog-owner.js";
import { getPreparedModelFullCatalogAuth } from "./prepared-model-catalog-worker.js";
import { PreparedModelCatalogConfigReplacedError } from "./prepared-model-catalog.errors.js";
import type { ResolvedPublishedModelCatalogOwner } from "./prepared-model-catalog.types.js";
import { copyPreparedModelRuntimeAuthState } from "./prepared-model-runtime-auth.js";
import {
getPreparedModelRuntimeAuthMaterializations,
setPreparedModelRuntimeAuthMaterializations,
setPreparedModelRuntimeAuthStore,
} from "./prepared-model-runtime-auth.js";
import { isPreparedModelCatalogFull } from "./prepared-model-runtime.facts.js";
import {
acquireAgentRunPreparedModelRuntime,
@@ -63,11 +68,22 @@ async function materializeRequestedModelCatalog(
return snapshot;
}
const modelCatalog = await snapshot.loadFullModelCatalog();
if (modelCatalog === snapshot.modelCatalog) {
return snapshot;
const fullAuth = getPreparedModelFullCatalogAuth(modelCatalog);
if (!fullAuth) {
throw new Error("prepared full model catalog omitted its auth generation");
}
const materialized = Object.freeze({ ...snapshot, modelCatalog });
copyPreparedModelRuntimeAuthState(snapshot, materialized);
const materialized = Object.freeze({
...snapshot,
authModes: fullAuth.authModes,
modelCatalog,
});
// A materialized full read owns the worker's refreshed auth generation. Do not copy the
// original loader or Gateway projection would start a second, split refresh after discovery.
setPreparedModelRuntimeAuthStore(materialized, fullAuth.authStore);
setPreparedModelRuntimeAuthMaterializations(
materialized,
getPreparedModelRuntimeAuthMaterializations(snapshot),
);
return materialized;
}
+81 -25
View File
@@ -1,5 +1,11 @@
/** Worker-thread entrypoint for complete model-catalog discovery. */
import { parentPort, workerData } from "node:worker_threads";
import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js";
import {
resolveAgentCredentialMapFromStore,
resolveUsableAgentCredentialModes,
} from "./agent-auth-credentials.js";
import { resolveAmbientAgentCredentialsForDiscovery } from "./agent-auth-discovery.js";
import { overlayExternalAuthProfiles } from "./auth-profiles/external-auth.js";
import { replaceRuntimeAuthProfileStoreSnapshots } from "./auth-profiles/runtime-snapshots.js";
import {
@@ -14,66 +20,114 @@ import {
} from "./prepared-model-catalog-worker.js";
import { AuthStorage } from "./sessions/auth-storage.js";
function refreshAuthStore(params: {
agentDir: string;
inheritedAuthDir?: string;
authStore: PreparedModelCatalogWorkerInput["authStore"];
config: PreparedModelCatalogWorkerInput["input"]["config"];
env: NodeJS.ProcessEnv;
providerIds?: readonly string[];
}) {
const durable = preserveResolvedSecretBackedCredentials({
next: loadAuthProfileStoreWithoutExternalProfiles(params.agentDir, {
allowKeychainPrompt: false,
...(params.inheritedAuthDir ? { inheritedAuthDir: params.inheritedAuthDir } : {}),
}),
existing: params.authStore,
});
const persistedProfileIds = new Set(params.authStore.runtimePersistedProfileIds ?? []);
const externalProfileIds = new Set(params.authStore.runtimeExternalProfileIds ?? []);
for (const [profileId, credential] of Object.entries(params.authStore.profiles)) {
if (
!persistedProfileIds.has(profileId) &&
!externalProfileIds.has(profileId) &&
durable.profiles[profileId] === undefined
) {
durable.profiles[profileId] = credential;
}
}
return overlayExternalAuthProfiles(durable, {
agentDir: params.agentDir,
config: params.config,
env: params.env,
...(params.providerIds ? { externalCliProviderIds: params.providerIds } : {}),
allowKeychainPrompt: false,
});
}
export async function runPreparedModelCatalogWorkerInput(
value: PreparedModelCatalogWorkerInput | PreparedModelAuthRefreshWorkerInput,
): Promise<PreparedModelWorkerResult> {
try {
if (value.kind === "auth-refresh") {
// 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",
kind: "auth-refresh",
generationFingerprint: value.generationFingerprint,
authStore: overlayExternalAuthProfiles(authStore, {
authStore: refreshAuthStore({
agentDir: value.agentDir,
inheritedAuthDir: value.inheritedAuthDir,
authStore: value.authStore,
config: value.config,
env: value.env,
externalCliProviderIds: value.providerIds,
allowKeychainPrompt: false,
providerIds: value.providerIds,
}),
};
}
const { prepareAgentCatalogSource, prepareFullCatalogFacts, prepareWorkspaceBuildGroup } =
await import("./prepared-model-runtime.facts.js");
replaceRuntimeAuthProfileStoreSnapshots([
{ agentDir: value.input.agentDir, store: value.authStore },
]);
const prepared = await prepareWorkspaceBuildGroup([value.input], "live");
const agentFacts = prepared.agentFacts[0];
if (!agentFacts) {
throw new Error("prepared model catalog worker produced no agent facts");
}
const exactAgentFacts = {
...agentFacts,
authStore: value.authStore,
templateAuthStorage: AuthStorage.inMemory({ ...value.credentials }),
credentials: value.credentials,
providerIds: [...value.providerIds],
};
const reconstructedFingerprint = fingerprintPreparedModelCatalogGeneration({
input: value.input,
authStore: value.authStore,
credentials: value.credentials,
providerIds: value.providerIds,
pluginMetadataSnapshot: prepared.pluginGeneration.pluginMetadataSnapshot,
});
if (reconstructedFingerprint !== value.generationFingerprint) {
throw new Error("prepared model catalog worker reconstructed a different runtime generation");
}
// Full discovery is one point-in-time operation: refresh first, then let every provider hook
// and the returned availability projection consume the same exact store.
const authStore = refreshAuthStore({
agentDir: value.input.agentDir,
inheritedAuthDir: value.input.inheritedAuthDir,
authStore: value.authStore,
config: value.input.config,
env: value.input.env ?? process.env,
});
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir: value.input.agentDir, store: authStore }]);
const ambientCredentials = withPluginRuntimeRegistryScope(
prepared.pluginGeneration.pluginRegistry,
() =>
resolveAmbientAgentCredentialsForDiscovery({
config: value.input.config,
env: value.input.env,
...(value.input.workspaceDir ? { workspaceDir: value.input.workspaceDir } : {}),
}),
);
const credentials = {
...ambientCredentials,
...resolveAgentCredentialMapFromStore(authStore, { config: value.input.config }),
};
const exactAgentFacts = {
...agentFacts,
authStore,
templateAuthStorage: AuthStorage.inMemory(credentials),
credentials,
providerIds: [...new Set([...value.providerIds, ...Object.keys(credentials)])].toSorted(
(left, right) => left.localeCompare(right),
),
};
const source = await prepareAgentCatalogSource(
exactAgentFacts,
prepared.pluginGeneration,
"live",
false,
{ authStore: value.authStore },
{ authStore },
);
const facts = await prepareFullCatalogFacts(
exactAgentFacts,
@@ -86,6 +140,8 @@ export async function runPreparedModelCatalogWorkerInput(
kind: "catalog",
generationFingerprint: value.generationFingerprint,
snapshot: facts.modelCatalog,
authStore,
authModes: resolveUsableAgentCredentialModes(credentials),
};
} catch (error) {
return { status: "failed", error: error instanceof Error ? error.message : String(error) };
@@ -118,9 +118,7 @@ function createFullModelCatalogAccess(params: {
pluginGeneration: PreparedModelRuntimePluginGeneration;
agentBuildCompletions: Map<string, Promise<void>>;
isCurrent: () => boolean;
eagerCatalog?: ModelCatalogSnapshot;
}): PreparedModelRuntimeCatalogAccess {
const eagerCatalog = params.eagerCatalog;
// Concurrent readers share discovery, but completed results are discarded so
// refreshable providers can publish changed inventory on the next explicit read.
let pending: Promise<ModelCatalogSnapshot> | undefined;
@@ -168,9 +166,6 @@ function createFullModelCatalogAccess(params: {
return promise;
},
loadFullModelCatalog: () => {
if (eagerCatalog) {
return Promise.resolve(eagerCatalog);
}
if (!pending) {
pending = runSerializedPreparedModelRuntimeTask({
agentDir: params.agentFacts.input.agentDir,
@@ -501,7 +496,6 @@ async function buildSnapshotBatch(
pluginGeneration,
agentBuildCompletions,
isCurrent: generationGuards.get(input) ?? (() => false),
...(catalogMode === "live" ? { eagerCatalog: catalogFacts.modelCatalog } : {}),
}),
),
pluginGeneration,
@@ -582,7 +582,7 @@ describe("prepared model runtime owner selection", () => {
}
});
it("serializes on-demand full catalogs while preserving agent credentials", async () => {
it("serializes on-demand full catalogs across prepared owners", async () => {
mocks.configuredAgentIds = ["agent-a", "agent-b"];
mocks.configuredWorkspaces.set("agent-a", "/tmp/shared-prepared-runtime-workspace");
mocks.configuredWorkspaces.set("agent-b", "/tmp/shared-prepared-runtime-workspace");
@@ -624,16 +624,6 @@ describe("prepared model runtime owner selection", () => {
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
expect(mocks.runPreparedModelCatalogWorker).toHaveBeenCalledTimes(2);
expect(peakActivePlans).toBe(1);
expect(
mocks.runPreparedModelCatalogWorker.mock.calls.map((call) => {
const credential = (call[0] as { input: { credentials: Record<string, unknown> } }).input
.credentials.custom as { type?: string; key?: string } | undefined;
if (credential?.type !== "api_key") {
throw new Error("expected prepared custom API key");
}
return credential.key;
}),
).toEqual(["test-key:/tmp/configured-agent-a", "test-key:/tmp/configured-agent-b"]);
});
it("serializes a lazy catalog plan before a superseding generation", async () => {
@@ -102,7 +102,6 @@ vi.mock("./prepared-model-catalog-worker.js", () => ({
agentFacts: {
input: unknown;
authStore: unknown;
credentials: unknown;
providerIds: unknown;
};
}) => ({
@@ -110,7 +109,6 @@ vi.mock("./prepared-model-catalog-worker.js", () => ({
generationFingerprint: "test-generation",
input: agentFacts.input,
authStore: agentFacts.authStore,
credentials: agentFacts.credentials,
providerIds: agentFacts.providerIds,
}),
runPreparedModelCatalogWorker: (...args: unknown[]) =>
@@ -7,7 +7,11 @@ import {
} from "../../agents/agent-auth-credentials.js";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { setPreparedModelRuntimeAuthStore } from "../../agents/prepared-model-runtime-auth.js";
import { setPreparedModelFullCatalogAuth } from "../../agents/prepared-model-catalog-worker.js";
import {
getPreparedModelRuntimeAuthStore,
setPreparedModelRuntimeAuthStore,
} from "../../agents/prepared-model-runtime-auth.js";
import type { PreparedModelRuntimeSnapshot } from "../../agents/prepared-model-runtime.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
@@ -490,10 +494,15 @@ describe("gateway chat metadata runtime", () => {
"openai",
"openai-chatgpt-responses",
);
const loadFullModelCatalog = vi.fn(async () => ({
const fullCatalog = {
...owner.modelCatalog,
providerOutcomes: [{ provider: "openai", status: "auth-rejected" as const }],
}));
};
setPreparedModelFullCatalogAuth(fullCatalog, {
authStore: getPreparedModelRuntimeAuthStore(owner)!,
authModes: owner.authModes,
});
const loadFullModelCatalog = vi.fn(async () => fullCatalog);
harness.setOwner({
...owner,
loadFullModelCatalog,