fix: self-heal executor credential resolution for custom providers
Stop synthesizing credentialInstanceId "default" into executor sessions, soft-fail unresolved instances to the legacy unscoped auth path, and collapse-match renamed custom-provider auth slugs so task execute matches chat.
This commit is contained in:
7
.changeset/credential-instance-self-heal.md
Normal file
7
.changeset/credential-instance-self-heal.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Self-heal executor credential resolution so custom providers and renames match chat.
|
||||
category: fix
|
||||
dev: Stop synthesizing credentialInstanceId "default" into executor sessions; soft-fail unresolved instances to the legacy unscoped auth path (customProviders.apiKey); collapse-match renamed custom-provider auth slugs when unique.
|
||||
@@ -1016,6 +1016,73 @@ describe("createResolvedAgentSession", () => {
|
||||
const passedTools = createSessionMock.mock.calls[0][0].customTools;
|
||||
expect(passedTools[0]).toBe(rawTool);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-03-17:35:
|
||||
Executor used to synthesize credentialInstanceId "default" and hard-fail when auth.json
|
||||
had no default instance for a custom provider. Chat omits the field and works via
|
||||
customProviders.apiKey. Session create must self-heal: continue without a scoped ref.
|
||||
*/
|
||||
it("self-heals missing credential instances instead of failing the session", async () => {
|
||||
const createSessionMock = vi.fn().mockResolvedValue({
|
||||
session: { prompt: vi.fn() },
|
||||
sessionFile: "session.json",
|
||||
});
|
||||
resolveRuntimeMock.mockResolvedValue({
|
||||
runtime: {
|
||||
id: "pi",
|
||||
name: "Default PI Runtime",
|
||||
createSession: createSessionMock,
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: vi.fn(() => "umansapi/model"),
|
||||
},
|
||||
runtimeId: "pi",
|
||||
wasConfigured: false,
|
||||
});
|
||||
|
||||
const emptyAuth = {
|
||||
reload() {},
|
||||
get: () => undefined,
|
||||
getAll: () => ({}),
|
||||
list: () => [],
|
||||
has: () => false,
|
||||
hasAuth: () => false,
|
||||
listInstances: () => [],
|
||||
getInstance: () => undefined,
|
||||
setInstance: async () => {},
|
||||
removeInstance: async () => {},
|
||||
getDefaultInstance: () => undefined,
|
||||
setDefaultInstance: async () => {},
|
||||
set: async () => {},
|
||||
remove: async () => {},
|
||||
logout: async () => {},
|
||||
getApiKey: async () => undefined,
|
||||
getOAuthProviders: () => [],
|
||||
login: async () => {},
|
||||
modify: async () => undefined,
|
||||
setModelRuntime: () => {},
|
||||
};
|
||||
|
||||
const { createResolvedAgentSession } = await import("../agents/agent-session-helpers.js");
|
||||
await expect(createResolvedAgentSession({
|
||||
sessionPurpose: "executor",
|
||||
cwd: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
defaultProvider: "umansapi",
|
||||
defaultModelId: "model",
|
||||
credentialInstanceId: "default",
|
||||
authStorage: emptyAuth as any,
|
||||
})).resolves.toMatchObject({ runtimeId: "pi" });
|
||||
|
||||
expect(createSessionMock).toHaveBeenCalledWith(expect.not.objectContaining({
|
||||
credentialInstanceId: expect.anything(),
|
||||
resolvedCredentialInstance: expect.anything(),
|
||||
}));
|
||||
// Unscoped legacy path: neither scoped field is set.
|
||||
const passed = createSessionMock.mock.calls[0][0];
|
||||
expect(passed.credentialInstanceId).toBeUndefined();
|
||||
expect(passed.resolvedCredentialInstance).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMergerSessionModel", () => {
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FusionAuthStorage } from "../auth/auth-storage.js";
|
||||
import { CredentialInstanceResolutionError, createFusionCredentialStore, resolveCredentialInstanceRef } from "../auth/auth-storage.js";
|
||||
import {
|
||||
CredentialInstanceResolutionError,
|
||||
createFusionCredentialStore,
|
||||
findRenamedProviderDefaultInstance,
|
||||
resolveCredentialInstanceRef,
|
||||
} from "../auth/auth-storage.js";
|
||||
|
||||
function storage(): FusionAuthStorage {
|
||||
const values = new Map([
|
||||
function storage(overrides?: Partial<FusionAuthStorage> & {
|
||||
values?: Map<string, { type: string; key: string }>;
|
||||
listProviders?: string[];
|
||||
defaults?: Record<string, { providerId: string; instanceId: string } | undefined>;
|
||||
}): FusionAuthStorage {
|
||||
const values = overrides?.values ?? new Map([
|
||||
["openai[work]", { type: "api_key", key: "work-key" }],
|
||||
["openai[personal]", { type: "api_key", key: "personal-key" }],
|
||||
["fallback", { type: "api_key", key: "fallback-key" }],
|
||||
]);
|
||||
const listProviders = overrides?.listProviders ?? ["openai", "fallback"];
|
||||
const defaults = overrides?.defaults ?? { openai: { providerId: "openai", instanceId: "work" } };
|
||||
const ref = (providerId: string, instanceId: string) => ({ providerId, instanceId });
|
||||
return {
|
||||
reload() {}, get: provider => values.get(provider), getAll: () => ({}), list: () => ["openai", "fallback"], has: () => true, hasAuth: () => true,
|
||||
reload() {},
|
||||
get: provider => values.get(provider) as never,
|
||||
getAll: () => ({}),
|
||||
list: () => listProviders,
|
||||
has: () => true,
|
||||
hasAuth: () => true,
|
||||
listInstances: provider => provider === "openai" ? [ref("openai", "work"), ref("openai", "personal")] : [],
|
||||
getInstance: item => values.get(`${item.providerId}[${item.instanceId}]`), setInstance: async (item, credential) => { values.set(`${item.providerId}[${item.instanceId}]`, credential); }, removeInstance: async () => {},
|
||||
getDefaultInstance: provider => provider === "openai" ? ref("openai", "work") : undefined, setDefaultInstance: async () => {},
|
||||
set: async () => {}, remove: async () => {}, logout: async () => {}, getApiKey: async () => undefined, getOAuthProviders: () => [], login: async () => {}, modify: async () => undefined, setModelRuntime: () => {},
|
||||
getInstance: item => {
|
||||
const named = values.get(`${item.providerId}[${item.instanceId}]`);
|
||||
if (named) return named as never;
|
||||
if (item.instanceId === "default") return values.get(item.providerId) as never;
|
||||
return undefined;
|
||||
},
|
||||
setInstance: async (item, credential) => { values.set(`${item.providerId}[${item.instanceId}]`, credential as never); },
|
||||
removeInstance: async () => {},
|
||||
getDefaultInstance: provider => defaults[provider] ?? (values.has(provider) ? ref(provider, "default") : undefined),
|
||||
setDefaultInstance: async () => {},
|
||||
set: async () => {},
|
||||
remove: async () => {},
|
||||
logout: async () => {},
|
||||
getApiKey: async () => undefined,
|
||||
getOAuthProviders: () => [],
|
||||
login: async () => {},
|
||||
modify: async () => undefined,
|
||||
setModelRuntime: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,7 +75,59 @@ describe("credential instance resolution", () => {
|
||||
const auth = storage();
|
||||
expect(resolveCredentialInstanceRef(auth, "openai", "deleted")).toMatchObject({ ref: { instanceId: "work" }, missing: true });
|
||||
expect(resolveCredentialInstanceRef(auth, "openai", "bad name")).toMatchObject({ ref: { instanceId: "work" }, missing: true });
|
||||
const noDefault = { ...auth, getDefaultInstance: () => undefined };
|
||||
const noDefault = storage({
|
||||
getDefaultInstance: () => undefined,
|
||||
listProviders: ["openai"],
|
||||
defaults: {},
|
||||
});
|
||||
expect(() => resolveCredentialInstanceRef(noDefault, "openai", "deleted")).toThrow(CredentialInstanceResolutionError);
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-03-17:35:
|
||||
Custom-provider rename leaves auth under a punctuation-variant slug while the model
|
||||
catalog uses the collapsed form. Exact alphanumeric collapse must self-heal when unique.
|
||||
*/
|
||||
it("self-heals slug punctuation renames (umans-api ↔ umansapi)", () => {
|
||||
const auth = storage({
|
||||
values: new Map([["umans-api", { type: "api_key", key: "k" }]]),
|
||||
listProviders: ["umans-api"],
|
||||
getDefaultInstance: (provider) => (provider === "umans-api" ? { providerId: "umans-api", instanceId: "default" } : undefined),
|
||||
});
|
||||
expect(findRenamedProviderDefaultInstance(auth, "umansapi")).toEqual({ providerId: "umans-api", instanceId: "default" });
|
||||
expect(resolveCredentialInstanceRef(auth, "umansapi", "default")).toMatchObject({
|
||||
ref: { providerId: "umans-api", instanceId: "default" },
|
||||
missing: true,
|
||||
renamedProvider: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cross-wire unrelated providers that share a prefix", () => {
|
||||
const auth = storage({
|
||||
values: new Map([
|
||||
["openai", { type: "api_key", key: "a" }],
|
||||
["openai-codex", { type: "api_key", key: "b" }],
|
||||
]),
|
||||
listProviders: ["openai", "openai-codex"],
|
||||
// No default for a missing sibling slug — only the two real providers exist.
|
||||
getDefaultInstance: (provider) => (
|
||||
provider === "openai" || provider === "openai-codex"
|
||||
? { providerId: provider, instanceId: "default" }
|
||||
: undefined
|
||||
),
|
||||
});
|
||||
expect(findRenamedProviderDefaultInstance(auth, "openai-extra")).toBeUndefined();
|
||||
expect(() => resolveCredentialInstanceRef(auth, "openai-extra", "default")).toThrow(CredentialInstanceResolutionError);
|
||||
});
|
||||
|
||||
it("throws when no auth default and no unique collapse rename match exist", () => {
|
||||
const auth = storage({
|
||||
values: new Map([["umans", { type: "api_key", key: "umans-key" }]]),
|
||||
listProviders: ["umans"],
|
||||
getDefaultInstance: (provider) => (provider === "umans" ? { providerId: "umans", instanceId: "default" } : undefined),
|
||||
});
|
||||
// umans vs umansapi do not collapse equal — session soft-heal covers this case via legacy path
|
||||
expect(findRenamedProviderDefaultInstance(auth, "umansapi")).toBeUndefined();
|
||||
expect(() => resolveCredentialInstanceRef(auth, "umansapi", "default")).toThrow(CredentialInstanceResolutionError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -881,16 +881,39 @@ export async function createResolvedAgentSession(
|
||||
const storage = injectedAuthStorage ?? createFusionAuthStorage();
|
||||
credentialResolution = resolveCredentialInstanceRef(storage, runtimeOptionsRaw.defaultProvider ?? "", requestedCredentialInstanceId);
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "CredentialInstanceResolutionError") throw error;
|
||||
sessionLog.warn(`[${sessionPurpose}] credential instance resolution unavailable; using provider default`);
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-03-17:35:
|
||||
CredentialInstanceResolutionError used to hard-fail the lane. Chat never passes a
|
||||
credentialInstanceId and succeeds via customProviders.apiKey registration; executor
|
||||
previously synthesized "default" and died when auth.json had no bare/default instance
|
||||
for that provider (custom providers, renames). Self-heal: drop the scoped selection and
|
||||
continue on the legacy unscoped path so the session matches chat. Still warn for audit.
|
||||
Other resolution errors remain soft as before.
|
||||
*/
|
||||
if ((error as Error).name === "CredentialInstanceResolutionError") {
|
||||
sessionLog.warn(
|
||||
`[${sessionPurpose}] credential instance "${requestedCredentialInstanceId}" for provider "${runtimeOptionsRaw.defaultProvider ?? ""}" unresolved; continuing with legacy provider auth (custom provider apiKey / unscoped default)`,
|
||||
);
|
||||
} else {
|
||||
sessionLog.warn(`[${sessionPurpose}] credential instance resolution unavailable; using provider default`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-08:10:
|
||||
A dangling named instance must be auditable: silent substitution spends the wrong account quota under an identity the operator did not select.
|
||||
|
||||
FNXC:ProviderAuth 2026-08-03-17:35:
|
||||
renamedProvider is the auth-key slug self-heal (custom provider rename left credentials under the previous registry key).
|
||||
*/
|
||||
if (credentialResolution?.missing) sessionLog.warn(`[${sessionPurpose}] requested credential instance is missing; using provider default`);
|
||||
if (credentialResolution?.missing) {
|
||||
sessionLog.warn(
|
||||
credentialResolution.renamedProvider
|
||||
? `[${sessionPurpose}] requested credential instance is missing; self-healed via renamed provider default "${credentialResolution.ref.providerId}/${credentialResolution.ref.instanceId}"`
|
||||
: `[${sessionPurpose}] requested credential instance is missing; using provider default`,
|
||||
);
|
||||
}
|
||||
|
||||
const skillNamesFromSelection = extractSkillNamesFromSelection(runtimeOptionsRaw.skillSelection);
|
||||
const mergedSkillNames = runtimeOptionsRaw.skills && runtimeOptionsRaw.skills.length > 0
|
||||
|
||||
@@ -251,11 +251,48 @@ export type CredentialInstanceResolution = {
|
||||
ref: ProviderInstanceRef;
|
||||
requestedInstanceId: string;
|
||||
missing: boolean;
|
||||
/**
|
||||
* True when the resolved provider id differs from the requested one after
|
||||
* rename/slug self-heal (ids/outcomes only — never credential material).
|
||||
*/
|
||||
renamedProvider?: boolean;
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-03-17:35:
|
||||
Custom-provider renames change the registry slug (e.g. "Umans API" → umans-api vs umansapi)
|
||||
while auth.json may still hold the previous bare key. Collapse alphanumerics so punctuation-
|
||||
only slug drift still resolves when exactly one auth provider collapses to the same id.
|
||||
Prefix matching is intentionally NOT used — openai vs openai-codex would cross-wire accounts.
|
||||
*/
|
||||
function collapseProviderIdForRenameMatch(providerId: string): string {
|
||||
return providerId.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
export function findRenamedProviderDefaultInstance(
|
||||
authStorage: FusionAuthStorage,
|
||||
providerId: string,
|
||||
): ProviderInstanceRef | undefined {
|
||||
if (!providerId || !isValidProviderId(providerId)) return undefined;
|
||||
const requestedCollapsed = collapseProviderIdForRenameMatch(providerId);
|
||||
if (!requestedCollapsed) return undefined;
|
||||
const candidates = authStorage.list().filter((candidateId) => {
|
||||
if (candidateId === providerId) return false;
|
||||
return collapseProviderIdForRenameMatch(candidateId) === requestedCollapsed;
|
||||
});
|
||||
if (candidates.length !== 1) return undefined;
|
||||
return authStorage.getDefaultInstance(candidates[0]!);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-01-08:10:
|
||||
A selected instance is resolved once before runtime dispatch and its concrete ref is passed forward. Re-resolving downstream could select a different default after audit, silently running work on an account the operator did not choose.
|
||||
|
||||
FNXC:ProviderAuth 2026-08-03-17:35:
|
||||
When the requested provider has no default instance, attempt rename self-heal before
|
||||
throwing. Executor lanes previously synthesized credentialInstanceId "default" for
|
||||
rotation bookkeeping; without a heal, custom providers authenticated only via
|
||||
customProviders.apiKey (chat parity) or renamed auth keys died at session create.
|
||||
*/
|
||||
export function resolveCredentialInstanceRef(
|
||||
authStorage: FusionAuthStorage,
|
||||
@@ -267,8 +304,14 @@ export function resolveCredentialInstanceRef(
|
||||
return { ref: requested, requestedInstanceId, missing: false };
|
||||
}
|
||||
const ref = authStorage.getDefaultInstance(providerId);
|
||||
if (!ref) throw new CredentialInstanceResolutionError(providerId, requestedInstanceId);
|
||||
return { ref, requestedInstanceId, missing: true };
|
||||
if (ref) {
|
||||
return { ref, requestedInstanceId, missing: true };
|
||||
}
|
||||
const renamed = findRenamedProviderDefaultInstance(authStorage, providerId);
|
||||
if (renamed) {
|
||||
return { ref: renamed, requestedInstanceId, missing: true, renamedProvider: true };
|
||||
}
|
||||
throw new CredentialInstanceResolutionError(providerId, requestedInstanceId);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -14420,9 +14420,20 @@ export class TaskExecutor {
|
||||
overrideColumnGovernsInitialSession ? undefined : activeAgentInstanceRef?.instanceId ?? detail.credentialInstanceId,
|
||||
);
|
||||
const { provider: executorProvider, modelId: executorModelId } = executorSessionModel;
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-08-03-17:35:
|
||||
Keep a synthetic "default" ref only for credential-rotation bookkeeping (startingInstanceId).
|
||||
Never force that synthetic id into createResolvedAgentSession: chat omits unset instance ids
|
||||
and custom providers authenticate via customProviders.apiKey. Passing "default" required an
|
||||
auth.json default instance and failed step-execute while chat with the same model worked.
|
||||
After a usage-limit rotation, agentDispatchedRotation is true and the offered instance is real.
|
||||
*/
|
||||
activeAgentInstanceRef ??= executorProvider
|
||||
? { providerId: executorProvider, instanceId: executorSessionModel.credentialInstanceId ?? DEFAULT_PROVIDER_INSTANCE_ID }
|
||||
: undefined;
|
||||
const sessionCredentialInstanceId = agentDispatchedRotation
|
||||
? activeAgentInstanceRef?.instanceId
|
||||
: executorSessionModel.credentialInstanceId;
|
||||
const { provider: executorFallbackProvider, modelId: executorFallbackModelId } = resolveExecutorFallbackModel(settings);
|
||||
const executorSessionThinkingSource = this.graphSeamThinkingLevel.get(task.id) ?? detail.thinkingLevel;
|
||||
const executorThinkingLevel = resolveExecutorThinkingLevel(executorSessionThinkingSource, settings);
|
||||
@@ -14523,7 +14534,7 @@ export class TaskExecutor {
|
||||
onToolEnd: agentLogger.onToolEnd,
|
||||
defaultProvider: executorProvider,
|
||||
defaultModelId: executorModelId,
|
||||
...(activeAgentInstanceRef ? { credentialInstanceId: activeAgentInstanceRef.instanceId } : {}),
|
||||
...(sessionCredentialInstanceId ? { credentialInstanceId: sessionCredentialInstanceId } : {}),
|
||||
fallbackProvider: executorFallbackProvider,
|
||||
fallbackModelId: executorFallbackModelId,
|
||||
fallbackThinkingLevel: executorFallbackThinkingLevel,
|
||||
|
||||
Reference in New Issue
Block a user