From 4ff41a723cf7eb14056b94174c56b9dc17ce1c37 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 3 Aug 2026 10:58:19 -0700 Subject: [PATCH] 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. --- .changeset/credential-instance-self-heal.md | 7 ++ .../__tests__/agent-session-helpers.test.ts | 67 ++++++++++++ .../credential-instance-resolution.test.ts | 100 ++++++++++++++++-- .../src/agents/agent-session-helpers.ts | 29 ++++- packages/engine/src/auth/auth-storage.ts | 47 +++++++- packages/engine/src/executor.ts | 13 ++- 6 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 .changeset/credential-instance-self-heal.md diff --git a/.changeset/credential-instance-self-heal.md b/.changeset/credential-instance-self-heal.md new file mode 100644 index 0000000000..5099ecfc49 --- /dev/null +++ b/.changeset/credential-instance-self-heal.md @@ -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. diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index e93303e37b..004545bcd7 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -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", () => { diff --git a/packages/engine/src/__tests__/credential-instance-resolution.test.ts b/packages/engine/src/__tests__/credential-instance-resolution.test.ts index 052a15b4f8..a47bedf0a1 100644 --- a/packages/engine/src/__tests__/credential-instance-resolution.test.ts +++ b/packages/engine/src/__tests__/credential-instance-resolution.test.ts @@ -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 & { + values?: Map; + listProviders?: string[]; + defaults?: Record; +}): 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); + }); }); diff --git a/packages/engine/src/agents/agent-session-helpers.ts b/packages/engine/src/agents/agent-session-helpers.ts index d582916f5f..b04777106d 100644 --- a/packages/engine/src/agents/agent-session-helpers.ts +++ b/packages/engine/src/agents/agent-session-helpers.ts @@ -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 diff --git a/packages/engine/src/auth/auth-storage.ts b/packages/engine/src/auth/auth-storage.ts index 6006f1e2b6..8af39b9cc7 100644 --- a/packages/engine/src/auth/auth-storage.ts +++ b/packages/engine/src/auth/auth-storage.ts @@ -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); } /* diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index ea1024b0d5..75acf4e5c5 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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,