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:
gsxdsm
2026-08-03 10:58:19 -07:00
parent 19f6456821
commit 4ff41a723c
6 changed files with 249 additions and 14 deletions

View 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.

View File

@@ -1016,6 +1016,73 @@ describe("createResolvedAgentSession", () => {
const passedTools = createSessionMock.mock.calls[0][0].customTools; const passedTools = createSessionMock.mock.calls[0][0].customTools;
expect(passedTools[0]).toBe(rawTool); 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", () => { describe("resolveMergerSessionModel", () => {

View File

@@ -1,20 +1,52 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { FusionAuthStorage } from "../auth/auth-storage.js"; 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 { function storage(overrides?: Partial<FusionAuthStorage> & {
const values = new Map([ 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[work]", { type: "api_key", key: "work-key" }],
["openai[personal]", { type: "api_key", key: "personal-key" }], ["openai[personal]", { type: "api_key", key: "personal-key" }],
["fallback", { type: "api_key", key: "fallback-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 }); const ref = (providerId: string, instanceId: string) => ({ providerId, instanceId });
return { 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")] : [], 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 () => {}, getInstance: item => {
getDefaultInstance: provider => provider === "openai" ? ref("openai", "work") : undefined, setDefaultInstance: async () => {}, const named = values.get(`${item.providerId}[${item.instanceId}]`);
set: async () => {}, remove: async () => {}, logout: async () => {}, getApiKey: async () => undefined, getOAuthProviders: () => [], login: async () => {}, modify: async () => undefined, setModelRuntime: () => {}, 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(); const auth = storage();
expect(resolveCredentialInstanceRef(auth, "openai", "deleted")).toMatchObject({ ref: { instanceId: "work" }, missing: true }); expect(resolveCredentialInstanceRef(auth, "openai", "deleted")).toMatchObject({ ref: { instanceId: "work" }, missing: true });
expect(resolveCredentialInstanceRef(auth, "openai", "bad name")).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); 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);
});
}); });

View File

@@ -881,16 +881,39 @@ export async function createResolvedAgentSession(
const storage = injectedAuthStorage ?? createFusionAuthStorage(); const storage = injectedAuthStorage ?? createFusionAuthStorage();
credentialResolution = resolveCredentialInstanceRef(storage, runtimeOptionsRaw.defaultProvider ?? "", requestedCredentialInstanceId); credentialResolution = resolveCredentialInstanceRef(storage, runtimeOptionsRaw.defaultProvider ?? "", requestedCredentialInstanceId);
} catch (error) { } 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: 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. 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 skillNamesFromSelection = extractSkillNamesFromSelection(runtimeOptionsRaw.skillSelection);
const mergedSkillNames = runtimeOptionsRaw.skills && runtimeOptionsRaw.skills.length > 0 const mergedSkillNames = runtimeOptionsRaw.skills && runtimeOptionsRaw.skills.length > 0

View File

@@ -251,11 +251,48 @@ export type CredentialInstanceResolution = {
ref: ProviderInstanceRef; ref: ProviderInstanceRef;
requestedInstanceId: string; requestedInstanceId: string;
missing: boolean; 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: 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. 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( export function resolveCredentialInstanceRef(
authStorage: FusionAuthStorage, authStorage: FusionAuthStorage,
@@ -267,8 +304,14 @@ export function resolveCredentialInstanceRef(
return { ref: requested, requestedInstanceId, missing: false }; return { ref: requested, requestedInstanceId, missing: false };
} }
const ref = authStorage.getDefaultInstance(providerId); const ref = authStorage.getDefaultInstance(providerId);
if (!ref) throw new CredentialInstanceResolutionError(providerId, requestedInstanceId); if (ref) {
return { ref, requestedInstanceId, missing: true }; 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);
} }
/* /*

View File

@@ -14420,9 +14420,20 @@ export class TaskExecutor {
overrideColumnGovernsInitialSession ? undefined : activeAgentInstanceRef?.instanceId ?? detail.credentialInstanceId, overrideColumnGovernsInitialSession ? undefined : activeAgentInstanceRef?.instanceId ?? detail.credentialInstanceId,
); );
const { provider: executorProvider, modelId: executorModelId } = executorSessionModel; 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 activeAgentInstanceRef ??= executorProvider
? { providerId: executorProvider, instanceId: executorSessionModel.credentialInstanceId ?? DEFAULT_PROVIDER_INSTANCE_ID } ? { providerId: executorProvider, instanceId: executorSessionModel.credentialInstanceId ?? DEFAULT_PROVIDER_INSTANCE_ID }
: undefined; : undefined;
const sessionCredentialInstanceId = agentDispatchedRotation
? activeAgentInstanceRef?.instanceId
: executorSessionModel.credentialInstanceId;
const { provider: executorFallbackProvider, modelId: executorFallbackModelId } = resolveExecutorFallbackModel(settings); const { provider: executorFallbackProvider, modelId: executorFallbackModelId } = resolveExecutorFallbackModel(settings);
const executorSessionThinkingSource = this.graphSeamThinkingLevel.get(task.id) ?? detail.thinkingLevel; const executorSessionThinkingSource = this.graphSeamThinkingLevel.get(task.id) ?? detail.thinkingLevel;
const executorThinkingLevel = resolveExecutorThinkingLevel(executorSessionThinkingSource, settings); const executorThinkingLevel = resolveExecutorThinkingLevel(executorSessionThinkingSource, settings);
@@ -14523,7 +14534,7 @@ export class TaskExecutor {
onToolEnd: agentLogger.onToolEnd, onToolEnd: agentLogger.onToolEnd,
defaultProvider: executorProvider, defaultProvider: executorProvider,
defaultModelId: executorModelId, defaultModelId: executorModelId,
...(activeAgentInstanceRef ? { credentialInstanceId: activeAgentInstanceRef.instanceId } : {}), ...(sessionCredentialInstanceId ? { credentialInstanceId: sessionCredentialInstanceId } : {}),
fallbackProvider: executorFallbackProvider, fallbackProvider: executorFallbackProvider,
fallbackModelId: executorFallbackModelId, fallbackModelId: executorFallbackModelId,
fallbackThinkingLevel: executorFallbackThinkingLevel, fallbackThinkingLevel: executorFallbackThinkingLevel,