mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(gateway): refresh chat metadata after runtime auth succeeds (#121193)
* fix(gateway): converge chat metadata after runtime auth * refactor(auth): isolate runtime materialization state * fix(auth): guard runtime route API types * fix(auth): default runtime transport override state * test(gateway): narrow prepared auth fixture * fix(auth): avoid recursive external auth publication * fix(auth): keep publication suppression internal * refactor(auth): hide runtime publication scope * fix(auth): isolate models list materialization * fix(gateway): type startup auth hydration deps * refactor(auth): extract materialization registration * chore(auth): keep materialization helper private
This commit is contained in:
committed by
GitHub
parent
ce133d4a35
commit
ba35d2a09a
@@ -11,6 +11,13 @@ import { addEnvBackedAgentCredentials } from "../agent-auth-discovery-core.js";
|
||||
import { overlayExternalAuthProfiles } from "./external-auth.js";
|
||||
import { testing } from "./external-auth.test-support.js";
|
||||
import { readExternalCliBootstrapCredential } from "./external-cli-sync.js";
|
||||
import { registerRuntimeAuthProfileStoreMutationListener } from "./runtime-snapshots.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
ensureAuthProfileStore,
|
||||
getRuntimeAuthProfileStoreSnapshot,
|
||||
replaceRuntimeAuthProfileStoreSnapshots,
|
||||
} from "./store.js";
|
||||
import type { AuthProfileStore, OAuthCredential } from "./types.js";
|
||||
|
||||
const resolveExternalAuthProfilesWithPluginsMock = vi.fn<
|
||||
@@ -55,6 +62,7 @@ function requireProfile(store: AuthProfileStore, profileId: string): Record<stri
|
||||
|
||||
describe("auth external oauth helpers", () => {
|
||||
beforeEach(() => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
resolveExternalAuthProfilesWithPluginsMock.mockReset();
|
||||
resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]);
|
||||
readCodexCliCredentialsCachedMock.mockReset();
|
||||
@@ -63,6 +71,7 @@ describe("auth external oauth helpers", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
testing.resetResolveExternalAuthProfilesForTest();
|
||||
});
|
||||
|
||||
@@ -105,6 +114,99 @@ describe("auth external oauth helpers", () => {
|
||||
expect(readCodexCliCredentialsCachedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("publishes a usable scoped CLI bootstrap into the runtime auth owner", () => {
|
||||
const agentDir = "/tmp/openclaw-external-oauth-publication";
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(
|
||||
createCredential({ expires: createUsableOAuthExpiry() }),
|
||||
);
|
||||
const listener = vi.fn();
|
||||
const unregister = registerRuntimeAuthProfileStoreMutationListener(listener);
|
||||
try {
|
||||
const scoped = ensureAuthProfileStore(agentDir, {
|
||||
externalCliProviderIds: ["openai"],
|
||||
allowKeychainPrompt: false,
|
||||
readOnly: true,
|
||||
syncExternalCli: false,
|
||||
});
|
||||
|
||||
expect(scoped.profiles["openai:default"]?.type).toBe("oauth");
|
||||
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]?.type).toBe(
|
||||
"oauth",
|
||||
);
|
||||
expect(listener).toHaveBeenCalledWith({ agentDir, affectsInheritedStores: false });
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not replace an explicit unresolved API-key profile with CLI OAuth", () => {
|
||||
const agentDir = "/tmp/openclaw-external-oauth-explicit-owner";
|
||||
const explicit = createStore({
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
});
|
||||
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: explicit }]);
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(
|
||||
createCredential({ expires: createUsableOAuthExpiry() }),
|
||||
);
|
||||
const listener = vi.fn();
|
||||
const unregister = registerRuntimeAuthProfileStoreMutationListener(listener);
|
||||
try {
|
||||
const scoped = ensureAuthProfileStore(agentDir, {
|
||||
externalCliProviderIds: ["openai"],
|
||||
allowKeychainPrompt: false,
|
||||
readOnly: true,
|
||||
syncExternalCli: false,
|
||||
});
|
||||
|
||||
expect(scoped.profiles["openai:default"]).toEqual(explicit.profiles["openai:default"]);
|
||||
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toEqual(
|
||||
explicit.profiles["openai:default"],
|
||||
);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves resolved runtime refs when startup publishes scoped external auth", () => {
|
||||
const agentDir = "/tmp/openclaw-external-oauth-prepared-owner";
|
||||
const resolved = createStore({
|
||||
"openai:configured": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "resolved-runtime-key",
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
});
|
||||
replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: resolved }]);
|
||||
readCodexCliCredentialsCachedMock.mockReturnValue(
|
||||
createCredential({ expires: createUsableOAuthExpiry() }),
|
||||
);
|
||||
const listener = vi.fn();
|
||||
const unregister = registerRuntimeAuthProfileStoreMutationListener(listener);
|
||||
try {
|
||||
const hydrated = ensureAuthProfileStore(agentDir, {
|
||||
externalCliProviderIds: ["openai"],
|
||||
allowKeychainPrompt: false,
|
||||
readOnly: true,
|
||||
syncExternalCli: false,
|
||||
});
|
||||
|
||||
expect(hydrated.profiles["openai:configured"]).toEqual(
|
||||
resolved.profiles["openai:configured"],
|
||||
);
|
||||
expect(hydrated.profiles["openai:default"]?.type).toBe("oauth");
|
||||
expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles).toEqual(hydrated.profiles);
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps ambient Codex OAuth from outranking an env key under an api-key pin", () => {
|
||||
const cfg = {
|
||||
models: {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import { resolveAuthProfileDatabasePath } from "./sqlite.js";
|
||||
|
||||
/** Secret-free proof that one exact provider/model transport completed with usable auth. */
|
||||
export type RuntimeAuthMaterialization = Readonly<{
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelApi: string;
|
||||
modelBaseUrl: string;
|
||||
requestTransportOverrides: "none" | "present";
|
||||
authMode: string;
|
||||
runtimeOwnerId: string;
|
||||
authProfileId?: string;
|
||||
}>;
|
||||
|
||||
type RuntimeAuthMaterializationMutationListener = (event: {
|
||||
agentDir?: string;
|
||||
affectsInheritedStores: boolean;
|
||||
}) => void;
|
||||
|
||||
const MAX_RUNTIME_AUTH_MATERIALIZATIONS_PER_OWNER = 64;
|
||||
const materializations = new Map<string, RuntimeAuthMaterialization[]>();
|
||||
const listeners = new Set<RuntimeAuthMaterializationMutationListener>();
|
||||
|
||||
function ownerKey(agentDir?: string): string {
|
||||
return resolveAuthProfileDatabasePath(agentDir);
|
||||
}
|
||||
|
||||
function notify(agentDir?: string): void {
|
||||
const event = {
|
||||
...(agentDir ? { agentDir } : {}),
|
||||
affectsInheritedStores: agentDir === undefined,
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerRuntimeAuthMaterializationMutationListener(
|
||||
listener: RuntimeAuthMaterializationMutationListener,
|
||||
): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Records successful auth at the boundary that proved one exact runtime route. */
|
||||
export function recordRuntimeAuthMaterialization(params: {
|
||||
agentDir?: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelApi: string;
|
||||
modelBaseUrl: string;
|
||||
requestTransportOverrides: "none" | "present";
|
||||
authMode: string;
|
||||
runtimeOwnerId: string;
|
||||
authProfileId?: string;
|
||||
}): boolean {
|
||||
const provider = normalizeProviderId(params.provider);
|
||||
const fact: RuntimeAuthMaterialization = {
|
||||
provider,
|
||||
modelId: params.modelId.trim().toLowerCase(),
|
||||
modelApi: params.modelApi.trim().toLowerCase(),
|
||||
modelBaseUrl: params.modelBaseUrl.trim(),
|
||||
requestTransportOverrides: params.requestTransportOverrides,
|
||||
authMode: params.authMode.trim().toLowerCase(),
|
||||
runtimeOwnerId: params.runtimeOwnerId.trim().toLowerCase(),
|
||||
...(params.authProfileId?.trim() ? { authProfileId: params.authProfileId.trim() } : {}),
|
||||
};
|
||||
if (Object.values(fact).some((value) => !value)) {
|
||||
return false;
|
||||
}
|
||||
const key = ownerKey(params.agentDir);
|
||||
const existing = materializations.get(key) ?? [];
|
||||
if (existing.some((candidate) => isDeepStrictEqual(candidate, fact))) {
|
||||
return false;
|
||||
}
|
||||
materializations.set(
|
||||
key,
|
||||
[...existing, fact].slice(-MAX_RUNTIME_AUTH_MATERIALIZATIONS_PER_OWNER),
|
||||
);
|
||||
notify(params.agentDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Revokes all facts backed by one runtime owner after a classified auth failure. */
|
||||
export function revokeRuntimeAuthMaterializations(params: {
|
||||
agentDir?: string;
|
||||
provider: string;
|
||||
runtimeOwnerId: string;
|
||||
}): boolean {
|
||||
const key = ownerKey(params.agentDir);
|
||||
const provider = normalizeProviderId(params.provider);
|
||||
const runtimeOwnerId = params.runtimeOwnerId.trim().toLowerCase();
|
||||
const existing = materializations.get(key);
|
||||
if (!provider || !runtimeOwnerId || !existing) {
|
||||
return false;
|
||||
}
|
||||
const next = existing.filter(
|
||||
(fact) => fact.provider !== provider || fact.runtimeOwnerId !== runtimeOwnerId,
|
||||
);
|
||||
if (next.length === existing.length) {
|
||||
return false;
|
||||
}
|
||||
if (next.length) {
|
||||
materializations.set(key, next);
|
||||
} else {
|
||||
materializations.delete(key);
|
||||
}
|
||||
notify(params.agentDir);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getPreparedRuntimeAuthMaterializations(
|
||||
agentDir?: string,
|
||||
): readonly RuntimeAuthMaterialization[] {
|
||||
return materializations.get(ownerKey(agentDir)) ?? [];
|
||||
}
|
||||
|
||||
export function clearRuntimeAuthMaterializations(agentDir?: string): void {
|
||||
materializations.delete(ownerKey(agentDir));
|
||||
}
|
||||
|
||||
export function clearAllRuntimeAuthMaterializations(): void {
|
||||
materializations.clear();
|
||||
}
|
||||
@@ -5,6 +5,12 @@
|
||||
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getPreparedRuntimeAuthMaterializations,
|
||||
recordRuntimeAuthMaterialization,
|
||||
registerRuntimeAuthMaterializationMutationListener,
|
||||
revokeRuntimeAuthMaterializations,
|
||||
} from "./runtime-materializations.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshot,
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
@@ -60,6 +66,97 @@ function expectOpenAICodexSnapshotCredential(
|
||||
}
|
||||
|
||||
describe("runtime auth profile snapshots", () => {
|
||||
it("marks default-owner materializations as inherited mutations", () => {
|
||||
const listener = vi.fn();
|
||||
const unregister = registerRuntimeAuthMaterializationMutationListener(listener);
|
||||
try {
|
||||
recordRuntimeAuthMaterialization({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
modelApi: "openai-chatgpt-responses",
|
||||
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
requestTransportOverrides: "none",
|
||||
authMode: "oauth",
|
||||
runtimeOwnerId: "codex",
|
||||
});
|
||||
|
||||
expect(listener).toHaveBeenCalledWith({ affectsInheritedStores: true });
|
||||
} finally {
|
||||
unregister();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
}
|
||||
});
|
||||
|
||||
it("publishes successful-auth facts without impersonating credential rotation", () => {
|
||||
const agentDir = "/tmp/openclaw-auth-runtime-materialized";
|
||||
const pluginStoreListener = vi.fn();
|
||||
const materializationListener = vi.fn();
|
||||
setRuntimeAuthProfileStoreSnapshot(createStore("materialized"), agentDir);
|
||||
const unregisterStore = registerRuntimeAuthProfileStoreMutationListener(pluginStoreListener);
|
||||
const unregisterMaterialization =
|
||||
registerRuntimeAuthMaterializationMutationListener(materializationListener);
|
||||
try {
|
||||
const materialization = {
|
||||
agentDir,
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
modelApi: "openai-chatgpt-responses",
|
||||
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
requestTransportOverrides: "none",
|
||||
authMode: "oauth",
|
||||
runtimeOwnerId: "codex",
|
||||
authProfileId: "openai:default",
|
||||
} as const;
|
||||
expect(recordRuntimeAuthMaterialization(materialization)).toBe(true);
|
||||
expect(recordRuntimeAuthMaterialization(materialization)).toBe(false);
|
||||
expect(getPreparedRuntimeAuthMaterializations(agentDir)).toEqual([
|
||||
{
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
modelApi: "openai-chatgpt-responses",
|
||||
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
requestTransportOverrides: "none",
|
||||
authMode: "oauth",
|
||||
runtimeOwnerId: "codex",
|
||||
authProfileId: "openai:default",
|
||||
},
|
||||
]);
|
||||
const sibling = { ...materialization, modelId: "gpt-5.5" };
|
||||
const distinctOwner = { ...materialization, runtimeOwnerId: "other-harness" };
|
||||
recordRuntimeAuthMaterialization(sibling);
|
||||
recordRuntimeAuthMaterialization(distinctOwner);
|
||||
expect(
|
||||
revokeRuntimeAuthMaterializations({
|
||||
agentDir,
|
||||
provider: "openai",
|
||||
runtimeOwnerId: "codex",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
revokeRuntimeAuthMaterializations({
|
||||
agentDir,
|
||||
provider: "openai",
|
||||
runtimeOwnerId: "codex",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(getPreparedRuntimeAuthMaterializations(agentDir)).toEqual([
|
||||
expect.objectContaining({ runtimeOwnerId: "other-harness", modelId: "gpt-5.4" }),
|
||||
]);
|
||||
expect(materializationListener).toHaveBeenCalledTimes(4);
|
||||
expect(pluginStoreListener).not.toHaveBeenCalled();
|
||||
|
||||
recordRuntimeAuthMaterialization(materialization);
|
||||
|
||||
setRuntimeAuthProfileStoreSnapshot(createStore("replaced"), agentDir);
|
||||
expect(getPreparedRuntimeAuthMaterializations(agentDir)).toEqual([]);
|
||||
expect(pluginStoreListener).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
unregisterMaterialization();
|
||||
unregisterStore();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
}
|
||||
});
|
||||
|
||||
it("notifies listeners only when credential ownership changes", () => {
|
||||
const agentDir = "/tmp/openclaw-auth-runtime-listener";
|
||||
const listener = vi.fn();
|
||||
|
||||
@@ -6,6 +6,10 @@ import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { cloneAuthProfileStore } from "./clone.js";
|
||||
import { mergeAuthProfileStores } from "./persisted.js";
|
||||
import {
|
||||
clearAllRuntimeAuthMaterializations,
|
||||
clearRuntimeAuthMaterializations,
|
||||
} from "./runtime-materializations.js";
|
||||
import { resolveAuthProfileDatabasePath } from "./sqlite.js";
|
||||
import type { AuthProfileStore, RuntimeAuthProfileStore } from "./types.js";
|
||||
|
||||
@@ -208,6 +212,13 @@ function notifyRuntimeAuthStoreMutation(agentDir?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function authProfilesChanged(
|
||||
previous: RuntimeAuthProfileStore | undefined,
|
||||
next: RuntimeAuthProfileStore | undefined,
|
||||
): boolean {
|
||||
return !isDeepStrictEqual(previous?.profiles ?? {}, next?.profiles ?? {});
|
||||
}
|
||||
|
||||
/** Observes credential snapshot changes at their lifecycle publication edge. */
|
||||
export function registerRuntimeAuthProfileStoreMutationListener(
|
||||
listener: RuntimeAuthProfileStoreMutationListener,
|
||||
@@ -283,6 +294,14 @@ export function replaceRuntimeAuthProfileStoreSnapshots(
|
||||
if (credentialsChanged) {
|
||||
runtimeAuthStoreCredentialsRevision += 1;
|
||||
}
|
||||
const next = new Map(
|
||||
entries.map((entry) => [resolveRuntimeStoreKey(entry.agentDir), entry.store] as const),
|
||||
);
|
||||
for (const key of new Set([...runtimeAuthStoreSnapshots.keys(), ...next.keys()])) {
|
||||
if (authProfilesChanged(runtimeAuthStoreSnapshots.get(key), next.get(key))) {
|
||||
clearRuntimeAuthMaterializations(path.dirname(key));
|
||||
}
|
||||
}
|
||||
recordChangedSnapshotRevisions(entries);
|
||||
runtimeAuthStoreSnapshots.clear();
|
||||
for (const entry of entries) {
|
||||
@@ -307,6 +326,7 @@ export function clearRuntimeAuthProfileStoreSnapshots(): void {
|
||||
runtimeAuthStoreSnapshotsRevision += 1;
|
||||
}
|
||||
runtimeAuthStoreSnapshots.clear();
|
||||
clearAllRuntimeAuthMaterializations();
|
||||
runtimeAuthStoreSnapshotRevisions.clear();
|
||||
if (snapshotsChanged) {
|
||||
notifyRuntimeAuthStoreMutation();
|
||||
@@ -325,6 +345,7 @@ export function clearRuntimeAuthProfileStoreSnapshot(agentDir?: string): boolean
|
||||
}
|
||||
runtimeAuthStoreSnapshotsRevision += 1;
|
||||
runtimeAuthStoreSnapshots.delete(key);
|
||||
clearRuntimeAuthMaterializations(agentDir);
|
||||
runtimeAuthStoreSnapshotRevisions.delete(key);
|
||||
notifyRuntimeAuthStoreMutation(agentDir);
|
||||
return true;
|
||||
@@ -346,6 +367,9 @@ export function setRuntimeAuthProfileStoreSnapshot(
|
||||
runtimeAuthStoreCredentialsRevision += 1;
|
||||
}
|
||||
const previousStore = runtimeAuthStoreSnapshots.get(key);
|
||||
if (authProfilesChanged(previousStore, store)) {
|
||||
clearRuntimeAuthMaterializations(agentDir);
|
||||
}
|
||||
const ownerChanged = !isDeepStrictEqual(ownerState(previousStore), ownerState(store));
|
||||
const snapshotChanged = !isDeepStrictEqual(previousStore, store);
|
||||
if (snapshotChanged) {
|
||||
@@ -381,6 +405,9 @@ export function noteRuntimeAuthProfileStorePersistedMutation(
|
||||
runtimeAuthStoreCredentialsRevision += 1;
|
||||
}
|
||||
const ownerKey = resolveRuntimeStoreKey(agentDir);
|
||||
if (mutation.credentialsChanged || mutation.profileSetChanged) {
|
||||
clearRuntimeAuthMaterializations(agentDir);
|
||||
}
|
||||
const record = getOrCreatePersistedMutationRecord(ownerKey);
|
||||
if (mutation.profileSetChanged) {
|
||||
record.profileSetRevision = persistedMutationRevision;
|
||||
|
||||
@@ -1170,7 +1170,23 @@ export function ensureAuthProfileStore(
|
||||
...externalCli,
|
||||
},
|
||||
);
|
||||
if (!runtimeStore || hasScopedExternalCliOverlay(externalCli)) {
|
||||
if (!runtimeStore) {
|
||||
if (
|
||||
hasScopedExternalCliOverlay(externalCli) &&
|
||||
(store.runtimeExternalProfileIds?.length ?? 0) > 0
|
||||
) {
|
||||
setRuntimeAuthProfileStoreSnapshot(store, effectiveAgentDir);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
if (hasScopedExternalCliOverlay(externalCli)) {
|
||||
// Scoped turn/control-plane resolution returns only the requested overlay, but the lifecycle
|
||||
// snapshot must retain unrelated external profiles. Publish the merged owner fact so prepared
|
||||
// model and chat metadata generations converge without reopening credential sources.
|
||||
const materialized = mergeRuntimeExternalProfileState({ next: store, existing: runtimeStore });
|
||||
if (!isDeepStrictEqual(materialized, runtimeStore)) {
|
||||
setRuntimeAuthProfileStoreSnapshot(materialized, effectiveAgentDir);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
return mergeRuntimeExternalProfileState({
|
||||
|
||||
@@ -264,6 +264,7 @@ export async function runPreparedEmbeddedLoop(
|
||||
getLastProfileId: () => preparedRuntime.snapshot().lastProfileId,
|
||||
getSessionId: () => sessionPromptState.sessionId,
|
||||
harnessOwnsTransport: () => preparedRuntime.snapshot().pluginHarnessOwnsTransport,
|
||||
getRuntimeAuthOwnerId: () => preparedRuntime.snapshot().agentHarness.id,
|
||||
getApiKeyInfo,
|
||||
});
|
||||
const ownsContextEngineLogicalTurnLease = params.contextEngineLogicalTurnLease === undefined;
|
||||
@@ -602,6 +603,11 @@ export async function runPreparedEmbeddedLoop(
|
||||
return terminalTimeoutResult;
|
||||
}
|
||||
|
||||
const terminalAuthPlan = preparedRuntime.snapshot().activePreparedAuthPlan;
|
||||
const requestTransportOverrides =
|
||||
terminalAuthPlan.modelRoute?.requestTransportOverrides ??
|
||||
terminalAuthPlan.deferredRouteSupport?.requestTransportOverrides ??
|
||||
"none";
|
||||
const terminalResolution = await resolveEmbeddedRunTerminal({
|
||||
runParams: params,
|
||||
retryState: terminalRetryState,
|
||||
@@ -638,6 +644,8 @@ export async function runPreparedEmbeddedLoop(
|
||||
modelId,
|
||||
modelTransportId: effectiveModel.id ?? modelId,
|
||||
modelTransportApi: effectiveModel.api ?? model.api,
|
||||
...(effectiveModel.baseUrl ? { modelTransportBaseUrl: effectiveModel.baseUrl } : {}),
|
||||
requestTransportOverrides,
|
||||
authProfileId: lastProfileId,
|
||||
profileFailureStore,
|
||||
attemptAuthProfileStore,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { sanitizeForLog } from "../../../../packages/terminal-core/src/ansi.js";
|
||||
import { MODEL_APIS, type ModelApi } from "../../../config/types.models.js";
|
||||
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../../infra/errors.js";
|
||||
import { redactIdentifier } from "../../../logging/redact-identifier.js";
|
||||
import type { ProviderRouteOverridePresence } from "../../../plugin-sdk/provider-model-types.js";
|
||||
import { resolveProviderModelRoutes } from "../../../plugins/provider-model-routes.js";
|
||||
import { looksLikeSecretSentinel, resolveSecretSentinel } from "../../../secrets/sentinel.js";
|
||||
import type { AuthProfileStore } from "../../auth-profiles.js";
|
||||
import { markAuthProfileSuccess } from "../../auth-profiles.js";
|
||||
import { recordRuntimeAuthMaterialization } from "../../auth-profiles/runtime-materializations.js";
|
||||
import {
|
||||
fingerprintAuthProfileOwnerShape,
|
||||
fingerprintAwsSdkRuntimeOwner,
|
||||
@@ -13,6 +18,7 @@ import {
|
||||
type AgentExecutionAuthBinding,
|
||||
} from "../../execution-auth-binding.js";
|
||||
import type { ResolvedProviderAuth } from "../../model-auth.js";
|
||||
import { modelMatchesProviderModelRoute } from "../../provider-model-route.js";
|
||||
import { log } from "../logger.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./types.js";
|
||||
|
||||
@@ -75,8 +81,12 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: {
|
||||
apiKeyInfo: ResolvedProviderAuth | null;
|
||||
attempt: EmbeddedRunAttemptResult;
|
||||
provider: string;
|
||||
agentDir?: string;
|
||||
modelId: string;
|
||||
modelApi: string;
|
||||
modelBaseUrl?: string;
|
||||
requestTransportOverrides?: ProviderRouteOverridePresence;
|
||||
config?: OpenClawConfig;
|
||||
agentHarnessId: string;
|
||||
pluginHarnessOwnsTransport: boolean;
|
||||
pluginHarnessOwnsAuthBootstrap: boolean;
|
||||
@@ -140,6 +150,20 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: {
|
||||
: input.pluginHarnessOwnsTransport
|
||||
? ("plugin-harness" as const)
|
||||
: undefined;
|
||||
const materializedRoute = resolveOpaqueHarnessMaterialization(input, credential);
|
||||
if (materializedRoute) {
|
||||
recordRuntimeAuthMaterialization({
|
||||
agentDir: input.agentDir,
|
||||
provider: input.provider,
|
||||
modelId: input.modelId,
|
||||
modelApi: materializedRoute.api,
|
||||
modelBaseUrl: materializedRoute.baseUrl,
|
||||
requestTransportOverrides: materializedRoute.requestTransportOverrides,
|
||||
authMode: materializedRoute.authRequirement === "subscription" ? "oauth" : "api-key",
|
||||
runtimeOwnerId: input.agentHarnessId,
|
||||
...(input.profileId ? { authProfileId: input.profileId } : {}),
|
||||
});
|
||||
}
|
||||
input.onSuccessfulAuthBinding?.({
|
||||
...(input.profileId ? { authProfileId: input.profileId } : {}),
|
||||
agentHarnessId: input.agentHarnessId,
|
||||
@@ -158,6 +182,63 @@ export function reportEmbeddedRunSuccessfulAuthBinding(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOpaqueHarnessMaterialization(
|
||||
input: Parameters<typeof reportEmbeddedRunSuccessfulAuthBinding>[0],
|
||||
credential: AuthProfileStore["profiles"][string] | undefined,
|
||||
) {
|
||||
if (
|
||||
!input.pluginHarnessOwnsAuthBootstrap ||
|
||||
input.apiKeyInfo ||
|
||||
hasInlineCredentialMaterial(credential) ||
|
||||
!isModelApi(input.modelApi)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const resolution = resolveProviderModelRoutes({
|
||||
provider: input.provider,
|
||||
modelId: input.modelId,
|
||||
api: input.modelApi,
|
||||
baseUrl: input.modelBaseUrl,
|
||||
config: input.config,
|
||||
requestTransportOverrides: input.requestTransportOverrides ?? "none",
|
||||
});
|
||||
if (resolution?.kind !== "routes") {
|
||||
return undefined;
|
||||
}
|
||||
const routes = resolution.routes.filter(
|
||||
(route) =>
|
||||
route.api === input.modelApi &&
|
||||
route.requestTransportOverrides === (input.requestTransportOverrides ?? "none") &&
|
||||
(!input.modelBaseUrl ||
|
||||
modelMatchesProviderModelRoute({
|
||||
provider: input.provider,
|
||||
api: input.modelApi,
|
||||
baseUrl: input.modelBaseUrl,
|
||||
route,
|
||||
})),
|
||||
);
|
||||
return routes.length === 1 ? routes[0] : undefined;
|
||||
}
|
||||
|
||||
function isModelApi(value: string): value is ModelApi {
|
||||
return (MODEL_APIS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function hasInlineCredentialMaterial(
|
||||
credential: AuthProfileStore["profiles"][string] | undefined,
|
||||
): boolean {
|
||||
if (!credential) {
|
||||
return false;
|
||||
}
|
||||
if (credential.type === "api_key") {
|
||||
return Boolean(credential.key?.trim());
|
||||
}
|
||||
if (credential.type === "token") {
|
||||
return Boolean(credential.token?.trim());
|
||||
}
|
||||
return Boolean(credential.access?.trim() && credential.refresh?.trim());
|
||||
}
|
||||
|
||||
function resolvePluginHarnessApiKeyInfo(input: {
|
||||
apiKeyInfo: ResolvedProviderAuth | null;
|
||||
pluginHarnessOwnsTransport: boolean;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
markAuthProfileFailure,
|
||||
markInlineProviderApiKeyFailure,
|
||||
} from "../../auth-profiles.js";
|
||||
import { revokeRuntimeAuthMaterializations } from "../../auth-profiles/runtime-materializations.js";
|
||||
import type { FailoverReason } from "../../embedded-agent-helpers.js";
|
||||
import { FailoverError, resolveFailoverStatus } from "../../failover-error.js";
|
||||
import { isConfigBackedInlineProviderApiKey, type ResolvedProviderAuth } from "../../model-auth.js";
|
||||
@@ -34,6 +35,7 @@ export function createEmbeddedRunFailoverRetryController(input: {
|
||||
getLastProfileId: () => string | undefined;
|
||||
getSessionId: () => string;
|
||||
harnessOwnsTransport: () => boolean;
|
||||
getRuntimeAuthOwnerId: () => string;
|
||||
getApiKeyInfo: () => ResolvedProviderAuth | null;
|
||||
}) {
|
||||
const {
|
||||
@@ -111,10 +113,17 @@ export function createEmbeddedRunFailoverRetryController(input: {
|
||||
reason?: AuthProfileFailureReason | null;
|
||||
modelId?: string;
|
||||
}) => {
|
||||
const { profileId, reason } = failure;
|
||||
if (input.harnessOwnsTransport() && (reason === "auth" || reason === "auth_permanent")) {
|
||||
revokeRuntimeAuthMaterializations({
|
||||
agentDir,
|
||||
provider,
|
||||
runtimeOwnerId: input.getRuntimeAuthOwnerId(),
|
||||
});
|
||||
}
|
||||
if (params.authProfileStateMode === "read-only") {
|
||||
return;
|
||||
}
|
||||
const { profileId, reason } = failure;
|
||||
if (!reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
||||
import { SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js";
|
||||
import { freezeDiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js";
|
||||
import type { AssistantMessage } from "../../../llm/types.js";
|
||||
import type { ProviderRouteOverridePresence } from "../../../plugin-sdk/provider-model-types.js";
|
||||
import { projectAgentRunAttemptTerminal } from "../../agent-run-terminal-outcome.js";
|
||||
import type { AuthProfileFailureReason, AuthProfileStore } from "../../auth-profiles.js";
|
||||
import type { AgentExecutionAuthBinding } from "../../execution-auth-binding.js";
|
||||
@@ -183,6 +184,8 @@ export async function resolveEmbeddedRunTerminal(input: {
|
||||
modelId: string;
|
||||
modelTransportId: string;
|
||||
modelTransportApi: string;
|
||||
modelTransportBaseUrl?: string;
|
||||
requestTransportOverrides?: ProviderRouteOverridePresence;
|
||||
authProfileId?: string;
|
||||
profileFailureStore: AuthProfileStore;
|
||||
attemptAuthProfileStore: AuthProfileStore;
|
||||
@@ -522,8 +525,12 @@ function completeEmbeddedRun(
|
||||
apiKeyInfo: input.apiKeyInfo,
|
||||
attempt: input.attempt,
|
||||
provider: input.provider,
|
||||
agentDir: input.runParams.agentDir,
|
||||
modelId: input.modelTransportId,
|
||||
modelApi: input.modelTransportApi,
|
||||
...(input.modelTransportBaseUrl ? { modelBaseUrl: input.modelTransportBaseUrl } : {}),
|
||||
requestTransportOverrides: input.requestTransportOverrides ?? "none",
|
||||
config: input.runParams.config,
|
||||
agentHarnessId: input.agentHarnessId,
|
||||
pluginHarnessOwnsTransport: input.pluginHarnessOwnsTransport,
|
||||
pluginHarnessOwnsAuthBootstrap: input.pluginHarnessOwnsAuthBootstrap,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "../plugin-sdk/provider-model-types.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import type { PreparedAgentCredentialModes } from "./agent-auth-credentials.js";
|
||||
import type { RuntimeAuthMaterialization } from "./auth-profiles/runtime-materializations.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
import {
|
||||
createModelAuthAvailabilityResolver,
|
||||
@@ -59,6 +60,7 @@ function evaluate(params: {
|
||||
preparedRuntimeAuthStore?: AuthProfileStore;
|
||||
syntheticAuthProviderRefs?: readonly string[];
|
||||
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
|
||||
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
|
||||
}) {
|
||||
return createModelAuthAvailabilityResolver({
|
||||
cfg: (params.cfg ?? {}) as OpenClawConfig,
|
||||
@@ -68,6 +70,7 @@ function evaluate(params: {
|
||||
syntheticAuthProviderRefs: params.syntheticAuthProviderRefs,
|
||||
preparedRuntimeAuthModes: params.preparedRuntimeAuthModes,
|
||||
preparedRuntimeAuthStore: params.preparedRuntimeAuthStore,
|
||||
preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations,
|
||||
}).evaluateModelAuth("openai", params.ref);
|
||||
}
|
||||
|
||||
@@ -158,6 +161,67 @@ describe("createModelAuthAvailabilityResolver", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps successful harness auth scoped to the exact model route", () => {
|
||||
const materialization = {
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
modelApi: "openai-chatgpt-responses",
|
||||
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
requestTransportOverrides: "none",
|
||||
authMode: "oauth",
|
||||
runtimeOwnerId: "codex",
|
||||
} as const;
|
||||
const store = authStore({
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluate({
|
||||
store,
|
||||
ref: { modelId: "gpt-5.4" },
|
||||
preparedRuntimeAuthMaterializations: [materialization],
|
||||
}),
|
||||
).toMatchObject({
|
||||
availability: true,
|
||||
evidence: "runtime",
|
||||
selectedRoute: subscriptionRoute,
|
||||
});
|
||||
expect(
|
||||
evaluate({
|
||||
store,
|
||||
ref: { modelId: "gpt-5.5" },
|
||||
preparedRuntimeAuthMaterializations: [materialization],
|
||||
}).availability,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
evaluate({
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
auth: "api-key",
|
||||
apiKey: "configured-platform-key",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
store,
|
||||
ref: { modelId: "gpt-5.4" },
|
||||
preparedRuntimeAuthMaterializations: [materialization],
|
||||
}),
|
||||
).toMatchObject({
|
||||
availability: true,
|
||||
evidence: "provider-config",
|
||||
selectedRoute: platformRoute,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "resolved", key: "runtime-key", availability: true },
|
||||
{ label: "unresolved", key: undefined, availability: undefined },
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
resolveSecretRefReadOnlyAvailability,
|
||||
resolveStoredCredentialReadOnlyAvailability,
|
||||
} from "./auth-profiles/read-only-availability.js";
|
||||
import type { RuntimeAuthMaterialization } from "./auth-profiles/runtime-materializations.js";
|
||||
import { getRuntimeAuthProfileStoreSnapshot } from "./auth-profiles/runtime-snapshots.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore } from "./auth-profiles/types.js";
|
||||
import {
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
selectProviderModelAuthSources,
|
||||
type ProviderModelAuthSourceSelection,
|
||||
} from "./provider-model-route-auth.js";
|
||||
import { modelMatchesProviderModelRoute } from "./provider-model-route.js";
|
||||
|
||||
const OPENAI_PROVIDER_ID = "openai";
|
||||
const OPENAI_CODEX_RESPONSES_API = "openai-chatgpt-responses";
|
||||
@@ -122,6 +124,7 @@ type CreateModelAuthAvailabilityResolverParams = {
|
||||
allowPreparedRuntimeAuth?: boolean;
|
||||
preparedRuntimeAuthStore?: AuthProfileStore;
|
||||
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
|
||||
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
|
||||
};
|
||||
|
||||
type AuthTarget = ModelAuthAvailabilityRef & {
|
||||
@@ -888,6 +891,81 @@ export function createModelAuthAvailabilityResolver(
|
||||
!modelLock && !awsSdkTerminal && basePolicy.binding.kind === "profile"
|
||||
? basePolicy.binding.profileId
|
||||
: undefined;
|
||||
const explicitProfileOrder = profileOrder(
|
||||
provider,
|
||||
ref.modelId,
|
||||
ref.preferredProfileId,
|
||||
ref.lockedProfileId,
|
||||
).hasExplicitOrder;
|
||||
const materializedModelId = ref.modelId
|
||||
? normalizeModelIdForProvider(provider, ref.modelId)?.toLowerCase()
|
||||
: undefined;
|
||||
const materialized =
|
||||
!modelLock &&
|
||||
!bindingProfileId &&
|
||||
!basePolicy.required &&
|
||||
!explicitProfileOrder &&
|
||||
materializedModelId
|
||||
? params.preparedRuntimeAuthMaterializations?.find(
|
||||
(fact) =>
|
||||
normalizeProvider(fact.provider) === provider &&
|
||||
fact.modelId === materializedModelId &&
|
||||
routeResolution.routes.some((route) => {
|
||||
const configuredRequirement =
|
||||
resolveProviderModelRouteAuthRequirement(configuredAuthMode);
|
||||
return (
|
||||
(!configuredRequirement || configuredRequirement === route.authRequirement) &&
|
||||
route.runtimePolicy?.compatibleIds.some(
|
||||
(runtimeId) => runtimeId.trim().toLowerCase() === fact.runtimeOwnerId,
|
||||
) === true &&
|
||||
route.api.toLowerCase() === fact.modelApi &&
|
||||
route.requestTransportOverrides === fact.requestTransportOverrides &&
|
||||
modelMatchesProviderModelRoute({
|
||||
provider,
|
||||
api: fact.modelApi,
|
||||
baseUrl: fact.modelBaseUrl,
|
||||
route,
|
||||
}) &&
|
||||
modeAllowed(
|
||||
provider,
|
||||
{
|
||||
...ref,
|
||||
api: route.api,
|
||||
baseUrl: route.baseUrl,
|
||||
authRequirement: route.authRequirement,
|
||||
},
|
||||
fact.authMode,
|
||||
)
|
||||
);
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
if (materialized) {
|
||||
const selectedRoute = routeResolution.routes.find(
|
||||
(route) =>
|
||||
route.runtimePolicy?.compatibleIds.some(
|
||||
(runtimeId) => runtimeId.trim().toLowerCase() === materialized.runtimeOwnerId,
|
||||
) === true &&
|
||||
route.api.toLowerCase() === materialized.modelApi &&
|
||||
route.requestTransportOverrides === materialized.requestTransportOverrides &&
|
||||
modelMatchesProviderModelRoute({
|
||||
provider,
|
||||
api: materialized.modelApi,
|
||||
baseUrl: materialized.modelBaseUrl,
|
||||
route,
|
||||
}),
|
||||
);
|
||||
if (selectedRoute) {
|
||||
return {
|
||||
availability: true,
|
||||
routeResolution,
|
||||
selectedRoute,
|
||||
selectedAuthMode: materialized.authMode,
|
||||
...(materialized.authProfileId ? { selectedProfileId: materialized.authProfileId } : {}),
|
||||
evidence: "runtime",
|
||||
};
|
||||
}
|
||||
}
|
||||
const selectedConfiguredMode = awsSdkTerminal
|
||||
? "aws-sdk"
|
||||
: bindingProfileId
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Secret-free successful-auth facts owned by an immutable prepared model generation. */
|
||||
import type { RuntimeAuthMaterialization } from "./auth-profiles/runtime-materializations.js";
|
||||
import type { PreparedModelRuntimeSnapshot } from "./prepared-model-runtime.types.js";
|
||||
|
||||
const materializationsBySnapshot = new WeakMap<
|
||||
PreparedModelRuntimeSnapshot,
|
||||
readonly RuntimeAuthMaterialization[]
|
||||
>();
|
||||
|
||||
export function setPreparedModelRuntimeAuthMaterializations(
|
||||
snapshot: PreparedModelRuntimeSnapshot,
|
||||
materializations: readonly RuntimeAuthMaterialization[],
|
||||
): void {
|
||||
materializationsBySnapshot.set(snapshot, materializations);
|
||||
}
|
||||
|
||||
export function getPreparedModelRuntimeAuthMaterializations(
|
||||
snapshot: PreparedModelRuntimeSnapshot,
|
||||
): readonly RuntimeAuthMaterialization[] {
|
||||
return materializationsBySnapshot.get(snapshot) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
getPreparedRuntimeAuthMaterializations,
|
||||
registerRuntimeAuthMaterializationMutationListener,
|
||||
type RuntimeAuthMaterialization,
|
||||
} from "./auth-profiles/runtime-materializations.js";
|
||||
import { setPreparedModelRuntimeAuthMaterializations } from "./prepared-model-runtime-auth.js";
|
||||
import {
|
||||
normalizeOptionalDir,
|
||||
type PreparedModelRuntimeOwner,
|
||||
} from "./prepared-model-runtime.owner.js";
|
||||
|
||||
type MaterializationMutationEvent = {
|
||||
agentDir?: string;
|
||||
affectsInheritedStores: boolean;
|
||||
};
|
||||
|
||||
export function registerPreparedRuntimeAuthMaterializationPublisher(
|
||||
owners: ReadonlyMap<string, PreparedModelRuntimeOwner>,
|
||||
notify: (event: { phase: "invalidated" | "published" }) => void,
|
||||
): () => void {
|
||||
return registerRuntimeAuthMaterializationMutationListener((event) => {
|
||||
publishPreparedRuntimeAuthMaterializations({
|
||||
event,
|
||||
owners,
|
||||
onInvalidated: () => notify({ phase: "invalidated" }),
|
||||
onPublished: () => notify({ phase: "published" }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function publishPreparedRuntimeAuthMaterializations(params: {
|
||||
event: MaterializationMutationEvent;
|
||||
owners: ReadonlyMap<string, PreparedModelRuntimeOwner>;
|
||||
onInvalidated: () => void;
|
||||
onPublished: () => void;
|
||||
read?: (agentDir?: string) => readonly RuntimeAuthMaterialization[];
|
||||
}): void {
|
||||
const event = {
|
||||
...params.event,
|
||||
agentDir: normalizeOptionalDir(params.event.agentDir),
|
||||
};
|
||||
const affectedOwners = [...params.owners.values()].flatMap((owner) => {
|
||||
const affected =
|
||||
event.affectsInheritedStores ||
|
||||
owner.input.agentDir === event.agentDir ||
|
||||
owner.input.inheritedAuthDir === event.agentDir;
|
||||
return affected && owner.snapshot && !owner.pending && !owner.needsRefresh
|
||||
? [{ owner, snapshot: owner.snapshot }]
|
||||
: [];
|
||||
});
|
||||
if (affectedOwners.length === 0) {
|
||||
return;
|
||||
}
|
||||
params.onInvalidated();
|
||||
const read = params.read ?? getPreparedRuntimeAuthMaterializations;
|
||||
for (const { owner, snapshot } of affectedOwners) {
|
||||
// A successful route only changes this bounded secret-free fact set. Rebuilding the model
|
||||
// catalog here would pull plugin lifecycle work into the turn-completion boundary.
|
||||
setPreparedModelRuntimeAuthMaterializations(
|
||||
snapshot,
|
||||
Object.freeze([...read(owner.input.agentDir)]),
|
||||
);
|
||||
}
|
||||
params.onPublished();
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import pLimit from "p-limit";
|
||||
import { withTimeout } from "../node-host/with-timeout.js";
|
||||
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
|
||||
import { resolveUsableAgentCredentialModes } from "./agent-auth-credentials.js";
|
||||
import { getPreparedRuntimeAuthMaterializations } from "./auth-profiles/runtime-materializations.js";
|
||||
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
|
||||
import { setPreparedModelRuntimeAuthMaterializations } from "./prepared-model-runtime-auth.js";
|
||||
import {
|
||||
PreparedModelRuntimePublicationSupersededError,
|
||||
toPreparedModelRuntimeError,
|
||||
@@ -177,7 +179,7 @@ function createSnapshot(
|
||||
const authStorage = AuthStorage.inMemory(credentials);
|
||||
return { authStorage, modelRegistry: templateModelRegistry.fork(authStorage) };
|
||||
};
|
||||
return Object.freeze({
|
||||
const snapshot: PreparedModelRuntimeSnapshot = Object.freeze({
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
agentDir: input.agentDir,
|
||||
activeProjectKeys: [],
|
||||
@@ -197,6 +199,11 @@ function createSnapshot(
|
||||
inlineProviderModels,
|
||||
createStores,
|
||||
});
|
||||
setPreparedModelRuntimeAuthMaterializations(
|
||||
snapshot,
|
||||
Object.freeze([...getPreparedRuntimeAuthMaterializations(input.agentDir)]),
|
||||
);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function buildSnapshotBatch(
|
||||
|
||||
@@ -135,8 +135,8 @@ function prepareAgentFacts(
|
||||
const env = input.env ?? process.env;
|
||||
const templateAuthStorage = discoverAuthStorage(input.agentDir, {
|
||||
config: input.config,
|
||||
// Snapshot construction never initializes, migrates, or externally syncs auth. ModelRegistry
|
||||
// discovery only parses the credential generation captured here.
|
||||
// Prepared owners consume only the already-published runtime auth generation. External CLI
|
||||
// hydration belongs to startup/control-plane and turn-time producers, never rebuilds.
|
||||
readOnly: true,
|
||||
ambientCredentials,
|
||||
...(input.skipCredentials ? { skipCredentials: true } : {}),
|
||||
|
||||
@@ -12,6 +12,8 @@ type StaticCatalogResolver = ReturnType<CreateStaticCatalogResolver>;
|
||||
|
||||
const preparedModelRuntimeMocks = vi.hoisted(() => ({
|
||||
preparedAuthStore: undefined as import("./auth-profiles/types.js").AuthProfileStore | undefined,
|
||||
preparedAuthMaterializations:
|
||||
[] as import("./auth-profiles/runtime-materializations.js").RuntimeAuthMaterialization[],
|
||||
authStorage: {
|
||||
getAll: vi.fn<() => AuthStorageData>(() => ({
|
||||
custom: { type: "api_key", key: "test-key" },
|
||||
@@ -52,6 +54,12 @@ const preparedModelRuntimeMocks = vi.hoisted(() => ({
|
||||
mutationListener: undefined as
|
||||
| ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void)
|
||||
| undefined,
|
||||
mutationListeners: new Set<
|
||||
(event: { agentDir?: string; affectsInheritedStores: boolean }) => void
|
||||
>(),
|
||||
materializationListeners: new Set<
|
||||
(event: { agentDir?: string; affectsInheritedStores: boolean }) => void
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock("./model-catalog.js", () => ({
|
||||
@@ -108,6 +116,70 @@ vi.mock("./agent-scope.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/runtime-materializations.js", () => ({
|
||||
getPreparedRuntimeAuthMaterializations: () =>
|
||||
preparedModelRuntimeMocks.preparedAuthMaterializations,
|
||||
registerRuntimeAuthMaterializationMutationListener: (
|
||||
listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void,
|
||||
) => {
|
||||
preparedModelRuntimeMocks.materializationListeners.add(listener);
|
||||
return () => preparedModelRuntimeMocks.materializationListeners.delete(listener);
|
||||
},
|
||||
recordRuntimeAuthMaterialization: (params: {
|
||||
agentDir?: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelApi: string;
|
||||
modelBaseUrl: string;
|
||||
requestTransportOverrides: "none" | "present";
|
||||
authMode: string;
|
||||
runtimeOwnerId: string;
|
||||
authProfileId?: string;
|
||||
}) => {
|
||||
preparedModelRuntimeMocks.preparedAuthMaterializations.push({
|
||||
provider: params.provider.trim().toLowerCase(),
|
||||
modelId: params.modelId.trim().toLowerCase(),
|
||||
modelApi: params.modelApi.trim().toLowerCase(),
|
||||
modelBaseUrl: params.modelBaseUrl,
|
||||
requestTransportOverrides: params.requestTransportOverrides,
|
||||
authMode: params.authMode.trim().toLowerCase(),
|
||||
runtimeOwnerId: params.runtimeOwnerId.trim().toLowerCase(),
|
||||
...(params.authProfileId ? { authProfileId: params.authProfileId } : {}),
|
||||
});
|
||||
const event = {
|
||||
agentDir: params.agentDir,
|
||||
affectsInheritedStores: params.agentDir === undefined,
|
||||
};
|
||||
for (const listener of preparedModelRuntimeMocks.materializationListeners) {
|
||||
listener(event);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
revokeRuntimeAuthMaterializations: (params: {
|
||||
agentDir?: string;
|
||||
provider: string;
|
||||
runtimeOwnerId: string;
|
||||
}) => {
|
||||
const previousLength = preparedModelRuntimeMocks.preparedAuthMaterializations.length;
|
||||
preparedModelRuntimeMocks.preparedAuthMaterializations =
|
||||
preparedModelRuntimeMocks.preparedAuthMaterializations.filter(
|
||||
(fact) =>
|
||||
fact.provider !== params.provider || fact.runtimeOwnerId !== params.runtimeOwnerId,
|
||||
);
|
||||
if (preparedModelRuntimeMocks.preparedAuthMaterializations.length === previousLength) {
|
||||
return false;
|
||||
}
|
||||
const event = {
|
||||
agentDir: params.agentDir,
|
||||
affectsInheritedStores: params.agentDir === undefined,
|
||||
};
|
||||
for (const listener of preparedModelRuntimeMocks.materializationListeners) {
|
||||
listener(event);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/runtime-snapshots.js", () => ({
|
||||
getPreparedRuntimeAuthProfileStoreSnapshot: () => preparedModelRuntimeMocks.preparedAuthStore,
|
||||
getRuntimeAuthProfileStoreSnapshot: () => preparedModelRuntimeMocks.preparedAuthStore,
|
||||
@@ -115,8 +187,9 @@ vi.mock("./auth-profiles/runtime-snapshots.js", () => ({
|
||||
registerRuntimeAuthProfileStoreMutationListener: (
|
||||
listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void,
|
||||
) => {
|
||||
preparedModelRuntimeMocks.mutationListener = listener;
|
||||
return () => {};
|
||||
preparedModelRuntimeMocks.mutationListener ??= listener;
|
||||
preparedModelRuntimeMocks.mutationListeners.add(listener);
|
||||
return () => preparedModelRuntimeMocks.mutationListeners.delete(listener);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -179,6 +252,7 @@ export function resetPreparedModelRuntimeHarness(): void {
|
||||
});
|
||||
preparedModelRuntimeMocks.authStorage.getOAuthProviders.mockReset().mockReturnValue([]);
|
||||
preparedModelRuntimeMocks.preparedAuthStore = undefined;
|
||||
preparedModelRuntimeMocks.preparedAuthMaterializations = [];
|
||||
preparedModelRuntimeMocks.modelRegistry.fork
|
||||
.mockReset()
|
||||
.mockImplementation((authStorage: unknown) => ({ authStorage }));
|
||||
|
||||
@@ -563,6 +563,43 @@ describe("prepared model runtime snapshots", () => {
|
||||
expect(secondStores.modelRegistry).not.toBe(firstStores.modelRegistry);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "usable",
|
||||
credential: { type: "oauth", access: "a", refresh: "r", expires: 1 },
|
||||
expected: "oauth",
|
||||
},
|
||||
{
|
||||
label: "unusable",
|
||||
credential: { type: "oauth", access: "", refresh: "", expires: 0 },
|
||||
expected: undefined,
|
||||
},
|
||||
] as const)(
|
||||
"consumes $label startup CLI hydration without rediscovery",
|
||||
async ({ credential, expected }) => {
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.4" },
|
||||
models: { "openai/gpt-5.4": {} },
|
||||
},
|
||||
},
|
||||
};
|
||||
mocks.authStorage.getAll.mockReturnValue({ openai: credential });
|
||||
|
||||
const snapshot = await publishPreparedModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: "/tmp/prepared-model-runtime-cli-startup",
|
||||
});
|
||||
|
||||
const discoveryOptions = mocks.discoverAuthStorage.mock.calls[0]?.[1] as {
|
||||
externalCli?: unknown;
|
||||
};
|
||||
expect(discoveryOptions.externalCli).toBeUndefined();
|
||||
expect(snapshot.authModes.openai).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores request config identity until lifecycle publication", async () => {
|
||||
const agentDir = "/tmp/prepared-model-runtime-request-config";
|
||||
const initialConfig = {};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
|
||||
import { registerRuntimeAuthProfileStoreMutationListener } from "./auth-profiles/runtime-snapshots.js";
|
||||
import { registerPreparedRuntimeAuthMaterializationPublisher } from "./prepared-model-runtime-materializations.js";
|
||||
import {
|
||||
PreparedModelRuntimeOwnerNotPublishedError,
|
||||
PreparedModelRuntimeOwnerRetention,
|
||||
@@ -748,6 +749,7 @@ function invalidateForAuthMutation(event: AuthMutationEvent): void {
|
||||
}
|
||||
|
||||
registerRuntimeAuthProfileStoreMutationListener(invalidateForAuthMutation);
|
||||
registerPreparedRuntimeAuthMaterializationPublisher(owners, notifyPreparedModelRuntimePublication);
|
||||
|
||||
function resetPreparedModelRuntimeSnapshotsForTest(): void {
|
||||
pendingModelRuntimeReplacement?.resolve();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import "../agents/prepared-model-runtime.test-harness.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { revokeRuntimeAuthMaterializations } from "../agents/auth-profiles/runtime-materializations.js";
|
||||
import { reportEmbeddedRunSuccessfulAuthBinding } from "../agents/embedded-agent-runner/run/auth-profile-success.js";
|
||||
import type { EmbeddedRunAttemptResult } from "../agents/embedded-agent-runner/run/types.js";
|
||||
import { getPreparedModelCatalogOwnerSnapshot } from "../agents/prepared-model-catalog.js";
|
||||
import { getPreparedModelRuntimeAuthMaterializations } from "../agents/prepared-model-runtime-auth.js";
|
||||
import { refreshPreparedModelRuntimeSnapshots } from "../agents/prepared-model-runtime.js";
|
||||
import {
|
||||
getPreparedModelRuntimeMocks,
|
||||
@@ -82,6 +86,22 @@ function configureAuthFixture(kind: "secret-ref" | "external-oauth" | "unresolve
|
||||
};
|
||||
}
|
||||
|
||||
function configureHarnessOwnedUnresolvedAuth() {
|
||||
mocks.authStorage.getAll.mockReturnValue({
|
||||
openai: { type: "api_key", key: "openclaw-secret-ref-configured" },
|
||||
});
|
||||
mocks.preparedAuthStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const sidecar of sidecars) {
|
||||
@@ -125,6 +145,7 @@ async function expectAvailable(
|
||||
metadataSnapshot: owner.metadataSnapshot,
|
||||
preparedAuthStore: mocks.preparedAuthStore ?? { version: 1, profiles: {} },
|
||||
preparedRuntimeAuthModes: owner.authModes,
|
||||
preparedRuntimeAuthMaterializations: getPreparedModelRuntimeAuthMaterializations(owner),
|
||||
});
|
||||
const [metadata, modelsList] = await Promise.all([
|
||||
lifecycle.read({ agentId: "main" }),
|
||||
@@ -182,6 +203,61 @@ describe("gateway chat metadata lifecycle composition", () => {
|
||||
await expectAvailable(lifecycle);
|
||||
});
|
||||
|
||||
it("publishes a successful harness auth binding before the next metadata read", async () => {
|
||||
configureHarnessOwnedUnresolvedAuth();
|
||||
await publishOwner();
|
||||
const lifecycle = await createLifecycle();
|
||||
await lifecycle.attachContext(context, sidecars);
|
||||
await expectAvailable(lifecycle, false);
|
||||
const profileStore = mocks.preparedAuthStore;
|
||||
if (!profileStore) {
|
||||
throw new Error("expected unresolved prepared auth store");
|
||||
}
|
||||
|
||||
reportEmbeddedRunSuccessfulAuthBinding({
|
||||
profileStore,
|
||||
apiKeyInfo: null,
|
||||
attempt: {
|
||||
runtimeArtifact: {
|
||||
id: "codex-app-server:test",
|
||||
fingerprint: "codex-runtime-fingerprint",
|
||||
},
|
||||
} as EmbeddedRunAttemptResult,
|
||||
provider: "openai",
|
||||
agentDir: "/tmp/configured-main",
|
||||
modelId: "gpt-5.4",
|
||||
modelApi: "openai-chatgpt-responses",
|
||||
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
requestTransportOverrides: "none",
|
||||
config,
|
||||
agentHarnessId: "codex",
|
||||
pluginHarnessOwnsTransport: true,
|
||||
pluginHarnessOwnsAuthBootstrap: true,
|
||||
});
|
||||
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
||||
expect(mocks.preparedAuthMaterializations).toEqual([
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
modelApi: "openai-chatgpt-responses",
|
||||
modelBaseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
requestTransportOverrides: "none",
|
||||
authMode: "oauth",
|
||||
runtimeOwnerId: "codex",
|
||||
}),
|
||||
]);
|
||||
|
||||
await vi.waitFor(async () => await expectAvailable(lifecycle));
|
||||
|
||||
revokeRuntimeAuthMaterializations({
|
||||
agentDir: "/tmp/configured-main",
|
||||
provider: "openai",
|
||||
runtimeOwnerId: "codex",
|
||||
});
|
||||
await vi.waitFor(async () => await expectAvailable(lifecycle, false));
|
||||
});
|
||||
|
||||
it("recovers a failed catch-up when the prepared owner publishes after attachment", async () => {
|
||||
const lifecycle = await createLifecycle();
|
||||
await lifecycle.attachContext(context, sidecars);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getPreparedModelCatalogOwnerSnapshot,
|
||||
type LoadPreparedModelCatalogParams,
|
||||
} from "../../agents/prepared-model-catalog.js";
|
||||
import { getPreparedModelRuntimeAuthMaterializations } from "../../agents/prepared-model-runtime-auth.js";
|
||||
import type { PreparedModelRuntimeSnapshot } from "../../agents/prepared-model-runtime.js";
|
||||
import { resolveSwarmConfig } from "../../agents/swarm-config.js";
|
||||
import { resolveRuntimeConfigCacheKey } from "../../config/runtime-snapshot.js";
|
||||
@@ -242,6 +243,9 @@ async function defaultBuildProjection(params: {
|
||||
preparedAuthStore: params.facts.authStore,
|
||||
// The owner records usable auth at discovery; metadata must share that exact generation fact.
|
||||
preparedRuntimeAuthModes: params.facts.owner.authModes,
|
||||
preparedRuntimeAuthMaterializations: getPreparedModelRuntimeAuthMaterializations(
|
||||
params.facts.owner,
|
||||
),
|
||||
...(params.preferredProfileId ? { preferredProfileId: params.preferredProfileId } : {}),
|
||||
...(params.lockedProfileId ? { lockedProfileId: params.lockedProfileId } : {}),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { PreparedAgentCredentialModes } from "../../agents/agent-auth-credentials.js";
|
||||
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
||||
import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js";
|
||||
import { resolveExternalCliAuthProfiles } from "../../agents/auth-profiles/external-cli-sync.js";
|
||||
import {
|
||||
recordRuntimeAuthMaterialization,
|
||||
type RuntimeAuthMaterialization,
|
||||
} from "../../agents/auth-profiles/runtime-materializations.js";
|
||||
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
|
||||
import {
|
||||
createModelAuthAvailabilityResolver,
|
||||
type ModelAuthAvailabilityEvaluation,
|
||||
type ModelAuthAvailabilityRef,
|
||||
type ModelAuthAvailabilityResolver,
|
||||
} from "../../agents/model-auth-availability.js";
|
||||
import { createOpenAIModelRoutesResolver } from "../../agents/openai-model-routes.js";
|
||||
@@ -41,6 +48,7 @@ export function createModelsListAuthResolver(params: {
|
||||
metadataSnapshot?: PluginMetadataSnapshot;
|
||||
preparedAuthStore?: AuthProfileStore;
|
||||
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
|
||||
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
|
||||
workspaceDir: string;
|
||||
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
|
||||
}): ModelAuthAvailabilityResolver {
|
||||
@@ -55,7 +63,17 @@ export function createModelsListAuthResolver(params: {
|
||||
// A prepared projection must hydrate from its own auth-store generation. Reading the global
|
||||
// snapshot can mix generations; treating this store as persisted loses resolved SecretRefs.
|
||||
const preparedRuntimeAuthStore = params.preparedAuthStore;
|
||||
return createModelAuthAvailabilityResolver({
|
||||
const externalCliProviderIds =
|
||||
!params.preparedAuthStore && params.includeOpenAIExternalProfiles ? ["openai"] : [];
|
||||
const externalProfileIds = new Set(
|
||||
externalCliProviderIds.length
|
||||
? resolveExternalCliAuthProfiles(authStore, {
|
||||
allowKeychainPrompt: false,
|
||||
providerIds: externalCliProviderIds,
|
||||
}).map(({ profileId }) => profileId)
|
||||
: [],
|
||||
);
|
||||
const resolver = createModelAuthAvailabilityResolver({
|
||||
cfg: params.cfg,
|
||||
authStore,
|
||||
agentDir,
|
||||
@@ -63,11 +81,52 @@ export function createModelsListAuthResolver(params: {
|
||||
env: process.env,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
preparedRuntimeAuthModes: params.preparedRuntimeAuthModes,
|
||||
preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations,
|
||||
skipSetupProviderFallback: true,
|
||||
syntheticAuthProviderRefs: listEnabledSyntheticAuthProviderRefs(params),
|
||||
externalCliProviderIds:
|
||||
!params.preparedAuthStore && params.includeOpenAIExternalProfiles ? ["openai"] : [],
|
||||
externalCliProviderIds,
|
||||
...(preparedRuntimeAuthStore ? { preparedRuntimeAuthStore } : {}),
|
||||
routeResolverFactory: params.routeResolverFactory,
|
||||
});
|
||||
if (externalProfileIds.size === 0) {
|
||||
return resolver;
|
||||
}
|
||||
const evaluateModelAuth = (
|
||||
provider: string,
|
||||
ref: ModelAuthAvailabilityRef = {},
|
||||
): ModelAuthAvailabilityEvaluation => {
|
||||
const evaluation = resolver.evaluateModelAuth(provider, ref);
|
||||
const route = evaluation.selectedRoute;
|
||||
const profileId = evaluation.selectedProfileId;
|
||||
if (
|
||||
evaluation.availability === true &&
|
||||
route &&
|
||||
profileId &&
|
||||
externalProfileIds.has(profileId)
|
||||
) {
|
||||
const modelId = ref.modelId?.trim();
|
||||
if (modelId) {
|
||||
recordRuntimeAuthMaterialization({
|
||||
agentDir,
|
||||
provider,
|
||||
modelId,
|
||||
modelApi: route.api,
|
||||
modelBaseUrl: route.baseUrl,
|
||||
requestTransportOverrides: route.requestTransportOverrides,
|
||||
authMode:
|
||||
evaluation.selectedAuthMode ??
|
||||
(route.authRequirement === "subscription" ? "oauth" : "api-key"),
|
||||
runtimeOwnerId: "external-cli",
|
||||
authProfileId: profileId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return evaluation;
|
||||
};
|
||||
return {
|
||||
...resolver,
|
||||
evaluateModelAuth,
|
||||
resolveProviderAuthAvailability: (provider, ref) =>
|
||||
evaluateModelAuth(provider, ref).availability,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import type { RuntimeAuthMaterialization } from "../../agents/auth-profiles/runtime-materializations.js";
|
||||
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
|
||||
import { DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
|
||||
@@ -283,6 +284,7 @@ export function createGatewayAgentModelCatalogProjector(params: {
|
||||
metadataSnapshot?: PluginMetadataSnapshot;
|
||||
preparedAuthStore?: AuthProfileStore;
|
||||
preparedRuntimeAuthModes?: PreparedAgentCredentialModes;
|
||||
preparedRuntimeAuthMaterializations?: readonly RuntimeAuthMaterialization[];
|
||||
preferredProfileId?: string;
|
||||
lockedProfileId?: string;
|
||||
routeResolverFactory?: typeof createOpenAIModelRoutesResolver;
|
||||
@@ -338,6 +340,7 @@ export function createGatewayAgentModelCatalogProjector(params: {
|
||||
metadataSnapshot,
|
||||
...(params.preparedAuthStore ? { preparedAuthStore: params.preparedAuthStore } : {}),
|
||||
preparedRuntimeAuthModes: params.preparedRuntimeAuthModes,
|
||||
preparedRuntimeAuthMaterializations: params.preparedRuntimeAuthMaterializations,
|
||||
workspaceDir,
|
||||
routeResolverFactory: params.routeResolverFactory,
|
||||
});
|
||||
|
||||
@@ -448,6 +448,67 @@ async function prewarmConfiguredPrimaryModel(params: {
|
||||
await publishConfiguredModelRuntimeSnapshots(params);
|
||||
}
|
||||
|
||||
type StartupExternalAuthHydrationDeps = {
|
||||
listAgentIds: (cfg: OpenClawConfig) => string[];
|
||||
resolveAgentDir: (cfg: OpenClawConfig, agentId: string) => string;
|
||||
collectConfiguredRefs: (cfg: OpenClawConfig, agentId: string) => readonly { value: string }[];
|
||||
hydrate: (agentDir: string, providers: readonly string[]) => void;
|
||||
};
|
||||
|
||||
async function hydrateConfiguredExternalCliAuth(params: {
|
||||
cfg: OpenClawConfig;
|
||||
log: { warn: (msg: string) => void };
|
||||
deps?: StartupExternalAuthHydrationDeps;
|
||||
}): Promise<void> {
|
||||
const deps: StartupExternalAuthHydrationDeps =
|
||||
params.deps ??
|
||||
(await Promise.all([
|
||||
import("../agents/agent-scope.js"),
|
||||
import("../agents/prepared-model-runtime.configured.js"),
|
||||
import("../agents/auth-profiles/store.js"),
|
||||
import("../agents/auth-profiles/external-cli-discovery.js"),
|
||||
]).then(([scope, configured, store, external]) => ({
|
||||
listAgentIds: scope.listAgentIds,
|
||||
resolveAgentDir: scope.resolveAgentDir,
|
||||
collectConfiguredRefs: configured.collectPreparedModelRuntimeConfiguredRefs,
|
||||
hydrate: (agentDir: string, providers: readonly string[]) => {
|
||||
const discovery = external.externalCliDiscoveryForProviders({
|
||||
cfg: params.cfg,
|
||||
providers,
|
||||
});
|
||||
if (discovery.mode === "none") {
|
||||
return;
|
||||
}
|
||||
store.ensureAuthProfileStore(agentDir, {
|
||||
config: params.cfg,
|
||||
externalCli: discovery,
|
||||
allowKeychainPrompt: false,
|
||||
readOnly: true,
|
||||
syncExternalCli: false,
|
||||
});
|
||||
},
|
||||
})));
|
||||
const hydratedDirs = new Set<string>();
|
||||
for (const agentId of deps.listAgentIds(params.cfg)) {
|
||||
const providers = deps.collectConfiguredRefs(params.cfg, agentId).flatMap(({ value }) => {
|
||||
const separator = value.indexOf("/");
|
||||
return separator > 0 ? [value.slice(0, separator)] : [];
|
||||
});
|
||||
const agentDir = deps.resolveAgentDir(params.cfg, agentId);
|
||||
if (providers.length === 0 || hydratedDirs.has(agentDir)) {
|
||||
continue;
|
||||
}
|
||||
hydratedDirs.add(agentDir);
|
||||
try {
|
||||
deps.hydrate(agentDir, providers);
|
||||
} catch (error) {
|
||||
params.log.warn(
|
||||
`startup external CLI auth hydration failed for agent ${agentId}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function publishConfiguredModelRuntimeSnapshots(params: {
|
||||
cfg: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
@@ -587,6 +648,9 @@ export async function startGatewaySidecars(params: {
|
||||
);
|
||||
}
|
||||
});
|
||||
await measureStartup(params.startupTrace, "sidecars.model-auth", () =>
|
||||
hydrateConfiguredExternalCliAuth({ cfg: params.cfg, log: params.log }),
|
||||
);
|
||||
// Agent RPC remains available when transports are disabled. Publish configured/static facts before
|
||||
// accepting work; live provider catalogs stay advisory and never enter the Gateway lifecycle.
|
||||
await measureStartup(params.startupTrace, "sidecars.model-runtime", () =>
|
||||
@@ -1385,6 +1449,7 @@ export const testing = {
|
||||
providerAuthPrewarmStartDelayMs: PROVIDER_AUTH_PREWARM_START_DELAY_MS,
|
||||
hasRestartSentinelFast,
|
||||
prewarmConfiguredPrimaryModel,
|
||||
hydrateConfiguredExternalCliAuth,
|
||||
publishConfiguredModelRuntimeSnapshots,
|
||||
publishStartupModelRuntime,
|
||||
refreshLatestUpdateRestartSentinelIfPresent,
|
||||
|
||||
@@ -37,6 +37,7 @@ vi.mock("../agents/prepared-model-runtime.js", () => ({
|
||||
}));
|
||||
|
||||
let prewarmConfiguredPrimaryModel: typeof import("./server-startup-post-attach.js").testing.prewarmConfiguredPrimaryModel;
|
||||
let hydrateConfiguredExternalCliAuth: typeof import("./server-startup-post-attach.js").testing.hydrateConfiguredExternalCliAuth;
|
||||
let publishStartupModelRuntime: typeof import("./server-startup-post-attach.js").testing.publishStartupModelRuntime;
|
||||
let shouldSkipStartupModelPrewarm: typeof import("./server-startup-post-attach.js").testing.shouldSkipStartupModelPrewarm;
|
||||
|
||||
@@ -45,6 +46,7 @@ describe("gateway startup primary model warmup", () => {
|
||||
({
|
||||
testing: {
|
||||
prewarmConfiguredPrimaryModel,
|
||||
hydrateConfiguredExternalCliAuth,
|
||||
publishStartupModelRuntime,
|
||||
shouldSkipStartupModelPrewarm,
|
||||
},
|
||||
@@ -79,6 +81,29 @@ describe("gateway startup primary model warmup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates configured external CLI auth before prepared owner publication", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const hydrate = vi.fn();
|
||||
|
||||
await hydrateConfiguredExternalCliAuth({
|
||||
cfg,
|
||||
log: { warn: vi.fn() },
|
||||
deps: {
|
||||
listAgentIds: () => ["main", "secondary"],
|
||||
resolveAgentDir: (_config, agentId) => `/tmp/${agentId}`,
|
||||
collectConfiguredRefs: (_config, agentId) => [
|
||||
{ value: agentId === "main" ? "openai/gpt-5.4" : "anthropic/sonnet-4.6" },
|
||||
],
|
||||
hydrate,
|
||||
},
|
||||
});
|
||||
|
||||
expect(hydrate).toHaveBeenCalledTimes(2);
|
||||
expect(hydrate).toHaveBeenCalledWith("/tmp/main", ["openai"]);
|
||||
expect(hydrate).toHaveBeenCalledWith("/tmp/secondary", ["anthropic"]);
|
||||
expect(refreshPreparedModelRuntimeSnapshotsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prewarms the default catalog when no explicit primary model is configured", async () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
await prewarmConfiguredPrimaryModel({
|
||||
|
||||
Reference in New Issue
Block a user