diff --git a/.changeset/fn-8661-credential-instance-resolution.md b/.changeset/fn-8661-credential-instance-resolution.md new file mode 100644 index 0000000000..8710558413 --- /dev/null +++ b/.changeset/fn-8661-credential-instance-resolution.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Honor selected credential instances when starting AI sessions. +category: feature +dev: Adds AgentRuntimeOptions.resolvedCredentialInstance and credentialInstanceId, optional ResolvedSessionOptions.authStorage, and /api/models providerInstances. diff --git a/AGENTS.md b/AGENTS.md index 792c835773..3e68d8723c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -300,6 +300,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - FN-7158: agent performance reflections emit `reflection:generated`, `reflection:skipped`, and `reflection:failed` with ids/counts/outcomes-only metadata; never persist reflection prose or prompt text in run-audit. - FN-7528: a deterministic, non-LLM post-task performance capture (`AgentReflectionService.captureTaskPerformance`) runs once per completed task and emits `reflection:captured` with ids/counts/outcomes-only metadata (`retryReworkCount?`, `filesTouchedCount?`, `packagesTouchedCount?`, `verificationFileScoped?`, `durationMs?`); never persists `verificationScopeReason` free-text or summary prose in run-audit. - FN-7787: `createResolvedAgentSession` enriches `session:runtime-resolved` with `noModelResolved: true` and `runtimeBuiltInFallbackModel` when a non-mock/non-test session reaches runtime creation without a complete provider/model pair; this is a visibility signal for runtime built-in fallback usage, not a fabricated model-resolution verdict. +- FN-8661: `session:runtime-resolved` records `credentialInstanceId` for a resolved explicit credential, plus `credentialInstanceMissing`, requested, and resolved instance ids when a dangling selection falls back to the provider default. Metadata is ids/outcomes-only and never includes credential material. - FN-7835/FN-7844/FN-7859/FN-7878: durable-agent error-state recovery emits `agent:auto-recover-error-state` when either the heartbeat timer or the self-healing sweep clears a recoverable, non-operator-actionable `error` and retries; metadata stays ids/counts/outcomes-only (`agentId`, attempt, limit, source), where `source` is `timer`/`automation`/`self-healing`. Generic/unknown heartbeat failures are recoverable by default because manual Retry often proves they were transient; both entry paths share the `heartbeatErrorRecovery` budget (self-healing keeps `durableErrorRecovery` only for cooldown/stale-path bookkeeping) and emit `agent:error-retry-exhausted` when the shared budget is exhausted and the agent is parked `paused` with `pauseReason:"error-retry-exhausted"`. Only operator-actionable durable heartbeat errors (credentials/OAuth scope, model access, billing/quota, excluding transient auth rotation), plus stale worktree/module-resolution errors handled by their dedicated suppression path, skip the retry budget and emit `agent:error-parked-unrecoverable` with ids/counts/outcomes-only metadata (`agentId`, `source`, optional `attempts`, `limit`) before parking `paused` with `pauseReason:"error-unrecoverable"` for human repair. - FN-7884: self-healing startup recovery emits `agent:reset-error-state-on-startup` when an engine restart clears an eligible durable-agent `error` or `pauseReason:"error-retry-exhausted"` park, resets shared `heartbeatErrorRecovery` plus legacy `durableErrorRecovery` budget/cooldown metadata, clears `lastError`/exhaustion pause state, and re-arms the heartbeat. Metadata stays ids/counts/outcomes-only (`agentId`, `priorState`, optional `priorPauseReason`, `source`). This startup-only path bypasses steady-state staleness/cooldown/exhaustion gates while preserving operator-actionable, stale-module, user-paused, `error-unrecoverable`, ephemeral, disabled-runtime, and active-execution suppression. - FN-7802: self-healing emits `task:reconcile-missing-worktree-merge-active` when it proves an `in-review` merge-active task (`merging`/`merging-pr`/`merging-fix`) is stranded by an unusable-worktree session-start failure, clears stale `worktree`/`branch`/`sessionFile`, resets the worktree-session retry budget, increments `recoveryRetryCount` as the bounded stale-metadata clear counter, and requeues to `todo`; it emits `task:reconcile-missing-worktree-merge-active-no-action` when `autoMerge:false`, workspace-task ownership, or triple-proof blocks the backward move. diff --git a/docs/architecture.md b/docs/architecture.md index 8479c606d0..f399923f02 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1129,7 +1129,7 @@ The run-audit system records every mutation performed by the engine across four - **Database / `task:reconcile-stale-agent-assignment`** — emitted when self-healing or heartbeat reconciliation clears stale durable `Agent.taskId`/`state` for a task parked in `todo`/`triage` without live execution proof. Metadata includes `{ agentId, taskId, taskColumn, agentState, status, blockedBy, overlapBlockedBy, hadFreshRun, hadActiveExecution, reason }`; task queue/lease fields are preserved. - **Database / `task:reconcile-stale-duplicate-decision`** — emitted when self-healing clears a triage-marker duplicate-decision pause whose canonical is missing, deleted, done, or archived. Metadata is ids/outcomes-only: `{ taskId, canonicalId, canonicalColumn, canonicalDeleted, priorPausedReason }`; active canonicals and user pauses are excluded. - **Database / `task:soft-delete-column-reconciled`** — emitted by `reconcileSoftDeletedColumnDrift` (FN-5566, re-land FN-5446) when a soft-deleted row (`deletedAt IS NOT NULL`) is found with legacy `column != 'archived'`; rewrites only `column` (no resurrection), with metadata `{ previousColumn }`. -- **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint? }` for per-lane runtime/provider attribution. +- **Database / `session:runtime-resolved`** — emitted once per `createResolvedAgentSession` call with metadata `{ sessionPurpose, runtimeId, wasConfigured, provider, modelId, mockProviderActive, testModeActive, runtimeHint?, credentialInstanceId?, credentialInstanceMissing?, requestedCredentialInstanceId?, resolvedCredentialInstanceId? }` for per-lane runtime/provider attribution. Credential fields are ids/outcomes-only; a missing selected instance is visibly recorded when the canonical provider default is used. - **Database / `task:reconcile-dependency-blocking-lease`** — emitted by `reconcileDependencyBlockingLeases()` (FN-6292) when self-healing rebounds an `in-progress` holder to `todo` because an unmet dependency is blocked by the holder's stale file-scope lease. Metadata includes the dependency ID, blocked-by marker, and unmet dependency list. - **Database / `task:reconcile-in-review-unmet-dependencies`** — emitted by `reconcileInReviewUnmetDependencies()` (FN-6793/FN-6797) when self-healing rebounds an `in-review` task to blocked `todo` because one or more declared dependencies are still unmet. Metadata includes `unmetDeps`, `blockedBy`, and prior review status; the `-no-action` companion is emitted when task pause/user-pause, `autoMerge:false`, live execution/checkout proof, or a failed rebound mutation prevents the backward move. - **Database / `task:reconcile-orphaned-task-dir`** — emitted by `TaskStore.reconcileOrphanedTaskDirs()` (FN-6783) when store open or self-healing Batch 1 re-imports a valid live `.fusion/tasks/{ID}/task.json` directory with no PostgreSQL task row anywhere. Metadata includes the recovered ID, column, status, and task JSON path. diff --git a/docs/secrets.md b/docs/secrets.md index 0551865866..b154be07a7 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -212,6 +212,8 @@ Fusion keeps provider credentials in `~/.fusion/agent/auth.json`. A legacy bare Legacy string APIs parse this grammar rather than accepting raw keys. `set("provider", credential)` updates the resolved default, or creates the bare key when none exists; `remove`, `logout`, `removeInstance`, and `modify` are no-ops when no instance exists. `setDefaultInstance` never creates a credential and rejects a missing target. All mutators, including deletes, reject the reserved metadata key. `list()` and `getAll()` remain logical-provider keyed (one resolved credential per provider); use `listInstances()` for individual instances. On-disk records are untrusted and values are type-filtered as credentials, so metadata and malformed values are never returned. External Claude/Codex hydration intentionally consumes bare keys only and ignores named instance keys. +At session creation, an explicitly selected credential instance wins over the provider default only for that provider. A missing named instance falls back to the canonical provider default and is recorded in `session:runtime-resolved`; Fusion never substitutes an arbitrary instance. If there is no default, session resolution fails without exposing credential material. Omitted instance ids retain the legacy default-resolving behavior. + Dashboard auth routes accept an optional instance id; omitted or blank ids retain default-instance behavior. `GET /api/auth/status?provider=&instance=` keeps the full provider envelope but describes the requested instance at that provider entry. A valid missing id is an unauthenticated `200` result, never a fallback to another account. Credential-establishing login and API-key writes may create a supplied id; rename, default, logout, and delete require an existing id. OAuth binds the id and optional opaque label to its server-side flow state, so a callback cannot fall back to the default account. Instance listings expose only ids, labels, auth status, and masked key hints; raw key and token material never leaves storage. `removeInstance` deletes the credential row and its default participation, while credential clear only removes its usable credential state. ## Operational Notes diff --git a/docs/settings-reference.md b/docs/settings-reference.md index e8cc5202e6..bed79c0035 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -73,6 +73,7 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | | `dismissModalsOnOutsideClick` | `boolean` | `false` | Global dashboard preference for closing fixed modal overlays by clicking/tapping the backdrop. Off by default to prevent accidental modal dismissal; explicit close, cancel, and Escape paths remain available. | | `skipConfirmationDialogs` | `boolean` | `false` | Global-only operator preference that skips centralized confirmation dialogs for critical actions. When enabled, destructive actions such as deleting a task or resetting progress immediately take the dialog's primary/default action; project settings cannot enable it for shared-project collaborators. | +| `credentialInstanceId` | `string` | `undefined` | Optional named credential instance for a resolved model selection. A named instance takes precedence over the provider default for that session and is scoped to that provider only; fallback providers retain their own default credentials. A deleted/malformed named instance uses the provider default and records `credentialInstanceMissing` with requested/resolved ids on `session:runtime-resolved`; no provider default is a visible resolution error. | | `defaultProvider` | `string` | `undefined` | Default AI provider. Anthropic has three independent surfaces, all executing on the direct `anthropic` provider except the CLI: (1) **direct OAuth** — a Claude subscription/OAuth login drives `anthropic/*` selections; Fusion sends the OAuth token to `https://api.anthropic.com/v1` with Claude Code identity headers (the same path the Claude Code CLI uses), so a subscription needs no API key. Credentials live under the `anthropic-subscription` auth/status/usage/banner id but are resolved for the direct provider at runtime; they are never stored or resolved as raw `ANTHROPIC_API_KEY` material. (2) **raw API key** — `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential uses `x-api-key` on the same direct provider and takes precedence over OAuth by default (see [`anthropicAuthPreference`](#anthropicauthpreference) to invert this). (3) **Claude CLI** — the explicit `pi-claude-cli` model provider runs sessions through the local `claude` CLI. There is no runtime rerouting between these surfaces. | | `defaultModelId` | `string` | `undefined` | Default AI model ID. | | `modelPricingOverrides` | `Record` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable from Settings → Global Models → View pricing table. | diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index de12e7ae17..067beede5b 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -394,6 +394,86 @@ describe("GET /models", () => { expect(res.body.models).toEqual([]); }); + it("returns instance availability for all configured providers without duplicating catalog models", async () => { + const modelRegistry = createMockModelRegistry(); + const authStorage = createMockAuthStorage({ + getOAuthProviders: vi.fn().mockReturnValue([{ id: "openai", name: "OpenAI" }]), + hasAuth: vi.fn((provider: string) => provider === "openai"), + listInstances: vi.fn((provider: string) => provider === "openai" + ? [{ providerId: "openai", instanceId: "work" }, { providerId: "openai", instanceId: "personal" }] + : []), + getDefaultInstance: vi.fn((provider: string) => provider === "openai" + ? { providerId: "openai", instanceId: "work" } + : undefined), + getInstance: vi.fn(() => ({ type: "api_key" })), + }); + + const res = await GET(buildApp(modelRegistry, authStorage), "/api/models"); + + expect(res.body.providerInstances).toEqual({ + openai: { instances: [{ id: "work", isDefault: true }, { id: "personal", isDefault: false }] }, + }); + expect(res.body.models.filter((model: { provider: string }) => model.provider === "openai")).toHaveLength(1); + }); + + it("derives per-instance unavailable models from the existing credential-kind provider gate", async () => { + const modelRegistry = createMockModelRegistry(); + const authStorage = createMockAuthStorage({ + getOAuthProviders: vi.fn().mockReturnValue([]), + getApiKeyProviders: vi.fn().mockReturnValue([{ id: "openai", name: "OpenAI" }]), + hasApiKey: vi.fn((provider: string) => provider === "openai"), + listInstances: vi.fn((provider: string) => provider === "openai" + ? [{ providerId: "openai", instanceId: "work" }, { providerId: "openai", instanceId: "oauth" }] + : []), + getDefaultInstance: vi.fn(() => ({ providerId: "openai", instanceId: "work" })), + getInstance: vi.fn((ref: { instanceId: string }) => ({ type: ref.instanceId === "work" ? "api_key" : "oauth" })), + }); + + const res = await GET(buildApp(modelRegistry, authStorage), "/api/models"); + + expect(res.body.providerInstances.openai.instances).toEqual([ + { id: "work", isDefault: true }, + { id: "oauth", isDefault: false, unavailableModelIds: ["gpt-4o"] }, + ]); + }); + + it("omits instance availability when auth storage is unavailable or lacks the optional capability", async () => { + const withoutStorage = await GET(buildApp(createMockModelRegistry()), "/api/models"); + expect(withoutStorage.body).not.toHaveProperty("providerInstances"); + + const withoutCapability = await GET(buildApp(createMockModelRegistry(), createMockAuthStorage()), "/api/models"); + expect(withoutCapability.body).not.toHaveProperty("providerInstances"); + }); + + it("skips a provider whose instance enumeration throws without failing the catalog", async () => { + const authStorage = createMockAuthStorage({ + getOAuthProviders: vi.fn(() => [{ id: "openai", name: "OpenAI" }]), + hasAuth: vi.fn(() => true), + listInstances: vi.fn(() => { throw new Error("corrupt instance metadata"); }), + }); + + const res = await GET(buildApp(createMockModelRegistry(), authStorage), "/api/models"); + + expect(res.status).toBe(200); + expect(res.body.models).toEqual(expect.any(Array)); + expect(res.body).not.toHaveProperty("providerInstances"); + }); + + it("omits unavailable model ids when instance credentials have no existing distinguishable signal", async () => { + const modelRegistry = createMockModelRegistry(); + const authStorage = createMockAuthStorage({ + listInstances: vi.fn(() => [{ providerId: "custom", instanceId: "one" }]), + getDefaultInstance: vi.fn(() => ({ providerId: "custom", instanceId: "one" })), + getInstance: vi.fn(() => ({ type: "api_key" })), + getOAuthProviders: vi.fn(() => [{ id: "custom", name: "Custom" }]), + hasAuth: vi.fn(() => true), + }); + + const res = await GET(buildApp(modelRegistry, authStorage), "/api/models"); + + expect(res.body.providerInstances.custom.instances).toEqual([{ id: "one", isDefault: true }]); + }); + it("returns empty array when registry has no available models", async () => { const modelRegistry = createMockModelRegistry({ getAvailable: vi.fn().mockReturnValue([]), diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index f4c256dc07..5ba557e92d 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -152,6 +152,82 @@ async function getConfiguredProviderNames(authStorage?: AuthStorageLike): Promis return providers; } +type ProviderCredential = { type?: unknown } | null | undefined; + +/** + * Return the models which today's configured-provider gate would advertise for a + * concrete credential kind. `undefined` means that the existing gate has no + * per-instance distinction for this provider, so callers must not invent one. + */ +function getAdvertisedModelIdsForCredential( + providerId: string, + credential: ProviderCredential, + models: Array<{ provider: string; id: string }>, + apiKeyProviderIds: Set, + oauthProviderIds: Set, +): Set | undefined { + const modelProviderId = toModelProviderId(providerId); + const providerModels = new Set(models.filter(model => model.provider === modelProviderId).map(model => model.id)); + if (providerModels.size === 0) return new Set(); + + // Direct Anthropic intentionally accepts both of its existing auth kinds. + if (modelProviderId === ANTHROPIC_PROVIDER_ID) { + return credential?.type === "api_key" || credential?.type === "oauth" ? providerModels : new Set(); + } + if (apiKeyProviderIds.has(providerId)) { + return credential?.type === "api_key" ? providerModels : new Set(); + } + if (oauthProviderIds.has(providerId)) { + return credential?.type === "oauth" ? providerModels : new Set(); + } + return undefined; +} + +/* +FNXC:ProviderAuth 2026-08-01-08:39: +Expose instance availability beside the catalog rather than copying every model per credential. Reuse the existing API-key/OAuth configured-provider gate to derive only real default-versus-instance deltas; an arbitrary stored field or a network probe would fabricate availability data. +*/ +function getProviderInstances( + authStorage: AuthStorageLike | undefined, + advertisedProviders: Iterable, + models: Array<{ provider: string; id: string }>, +): Record }> | undefined { + if (!authStorage?.listInstances) return undefined; + const result: Record }> = {}; + const apiKeyProviderIds = new Set((authStorage.getApiKeyProviders?.() ?? []).map(provider => provider.id)); + const oauthProviderIds = new Set((authStorage.getOAuthProviders?.() ?? []).map(provider => provider.id)); + const providerIds = new Set([...advertisedProviders, ...models.map(model => model.provider)]); + for (const modelProviderId of providerIds) { + const providerId = modelProviderId === ANTHROPIC_PROVIDER_ID ? ANTHROPIC_PROVIDER_ID : modelProviderId; + try { + const defaultRef = authStorage.getDefaultInstance?.(providerId); + const refs = authStorage.listInstances(providerId); + if (refs.length === 0) continue; + const defaultCredential = defaultRef && authStorage.getInstance?.(defaultRef); + const defaultModelIds = getAdvertisedModelIdsForCredential( + providerId, defaultCredential, models, apiKeyProviderIds, oauthProviderIds, + ); + const instances = refs.map(ref => { + const instanceModelIds = getAdvertisedModelIdsForCredential( + providerId, authStorage.getInstance?.(ref), models, apiKeyProviderIds, oauthProviderIds, + ); + const unavailableModelIds = defaultModelIds && instanceModelIds + ? [...defaultModelIds].filter(modelId => !instanceModelIds.has(modelId)) + : []; + return { + id: ref.instanceId, + isDefault: defaultRef?.instanceId === ref.instanceId, + ...(unavailableModelIds.length > 0 ? { unavailableModelIds } : {}), + }; + }); + result[modelProviderId] = { instances }; + } catch { + // A corrupt provider entry must not make the shared model catalog unavailable. + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { const { router, options, store, runtimeLogger } = ctx; @@ -490,6 +566,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { configuredProviders.add(customProviderRegistryKey(provider, customProviders)); } models = models.filter((m) => configuredProviders.has(m.provider)); + const providerInstances = getProviderInstances(options?.authStorage, configuredProviders, models); res.json({ models, @@ -497,6 +574,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { favoriteModels, ...defaultModelResponse, ...resolvedPlanningModelResponse, + ...(providerInstances ? { providerInstances } : {}), }); } catch (err: unknown) { if (err instanceof ApiError) { diff --git a/packages/engine/src/__tests__/agent-session-helpers.test.ts b/packages/engine/src/__tests__/agent-session-helpers.test.ts index 43534bad86..c80c9ea9f5 100644 --- a/packages/engine/src/__tests__/agent-session-helpers.test.ts +++ b/packages/engine/src/__tests__/agent-session-helpers.test.ts @@ -244,6 +244,22 @@ describe("resolve session model parity", () => { defaultModelId: "glm-5.1", }; + it("carries the winning selection credential instance alongside its provider and model", () => { + expect(resolveExecutorSessionModel("task-provider", "task-model", settings, undefined, "personal")).toEqual({ + provider: "task-provider", + modelId: "task-model", + credentialInstanceId: "personal", + }); + expect(resolvePlanningSessionModel(undefined, undefined, { + ...settings, + planningCredentialInstanceId: "work", + })).toEqual({ + provider: "anthropic", + modelId: "claude-sonnet-4-5", + credentialInstanceId: "work", + }); + }); + it("uses the same fresh settings model for executor and heartbeat when runtimeConfig is absent", () => { const executor = resolveExecutorSessionModel(undefined, undefined, settings); const heartbeat = resolveHeartbeatSessionModels(settings); diff --git a/packages/engine/src/__tests__/credential-instance-resolution.test.ts b/packages/engine/src/__tests__/credential-instance-resolution.test.ts new file mode 100644 index 0000000000..f0a855ac94 --- /dev/null +++ b/packages/engine/src/__tests__/credential-instance-resolution.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { FusionAuthStorage } from "../auth-storage.js"; +import { CredentialInstanceResolutionError, createFusionCredentialStore, resolveCredentialInstanceRef } from "../auth-storage.js"; + +function storage(): FusionAuthStorage { + const 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 ref = (providerId: string, instanceId: string) => ({ providerId, instanceId }); + return { + reload() {}, get: provider => values.get(provider), getAll: () => ({}), list: () => ["openai", "fallback"], 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: () => {}, + }; +} + +describe("credential instance resolution", () => { + it("uses the explicit instance while fallback providers keep their default behavior", async () => { + const auth = storage(); + const resolution = resolveCredentialInstanceRef(auth, "openai", "personal"); + expect(resolution).toMatchObject({ ref: { providerId: "openai", instanceId: "personal" }, missing: false }); + const credentials = createFusionCredentialStore(auth, resolution.ref); + expect(await credentials.read("openai")).toMatchObject({ key: "personal-key" }); + expect(await credentials.read("fallback")).toMatchObject({ key: "fallback-key" }); + }); + + it("keeps Anthropic on the instance-aware getApiKey indirection", async () => { + const auth = storage(); + const getApiKey = async (provider: string, instance?: { providerId: string; instanceId: string }) => { + expect(provider).toBe("anthropic"); + expect(instance).toEqual({ providerId: "anthropic", instanceId: "personal" }); + return "instance-token"; + }; + const credentials = createFusionCredentialStore({ ...auth, getApiKey }, { providerId: "anthropic", instanceId: "personal" }); + expect(await credentials.read("anthropic")).toEqual({ type: "api_key", key: "instance-token" }); + }); + + it("audits a missing or malformed name by resolving only the provider default", () => { + 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 }; + expect(() => resolveCredentialInstanceRef(noDefault, "openai", "deleted")).toThrow(CredentialInstanceResolutionError); + }); +}); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 0755f94b02..2b6e8789e5 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -3032,6 +3032,7 @@ export class HeartbeatMonitor { customTools: heartbeatTools, defaultProvider: heartbeatSessionModels.defaultProvider, defaultModelId: heartbeatSessionModels.defaultModelId, + ...(heartbeatSessionModels.credentialInstanceId ? { credentialInstanceId: heartbeatSessionModels.credentialInstanceId } : {}), fallbackProvider: heartbeatSessionModels.fallbackProvider, fallbackModelId: heartbeatSessionModels.fallbackModelId, fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, heartbeatModelSettings), diff --git a/packages/engine/src/agent-runtime.ts b/packages/engine/src/agent-runtime.ts index b65b6dffac..d6f17ad5e4 100644 --- a/packages/engine/src/agent-runtime.ts +++ b/packages/engine/src/agent-runtime.ts @@ -15,7 +15,7 @@ */ import type { AgentSession, SessionManager, ToolDefinition } from "@earendil-works/pi-coding-agent"; -import type { PermanentAgentGatingContext, ResolvedMcpServerDefinition } from "@fusion/core"; +import type { PermanentAgentGatingContext, ProviderInstanceRef, ResolvedMcpServerDefinition } from "@fusion/core"; import type { SkillSelectionContext } from "./skill-resolver.js"; import type { FallbackModelUsedPayload } from "./pi.js"; import type { AgentActionGateContext } from "./agent-action-gate.js"; @@ -103,6 +103,13 @@ export interface AgentRuntimeOptions { defaultProvider?: string; /** Default model ID within the provider (e.g. "claude-sonnet-4-5") */ defaultModelId?: string; + /* + FNXC:ProviderAuth 2026-08-01-08:10: + Session creation carries the already-resolved credential ref rather than a raw id so audit and the credential store cannot drift if a provider default changes mid-startup. + */ + resolvedCredentialInstance?: ProviderInstanceRef; + /** Informational operator-requested instance id; the concrete ref above is authoritative. */ + credentialInstanceId?: string; /** Optional fallback model provider for retryable errors */ fallbackProvider?: string; /** Optional fallback model ID */ diff --git a/packages/engine/src/agent-session-helpers.ts b/packages/engine/src/agent-session-helpers.ts index 992082712c..5e0fda0ce8 100644 --- a/packages/engine/src/agent-session-helpers.ts +++ b/packages/engine/src/agent-session-helpers.ts @@ -43,6 +43,7 @@ import { type FallbackModelUsedPayload, } from "./pi.js"; import type { RunAuditor } from "./run-audit.js"; +import { createFusionAuthStorage, resolveCredentialInstanceRef, type FusionAuthStorage } from "./auth-storage.js"; import { MockAgentRuntime } from "./providers/mock-provider.js"; /** Logger for agent session helpers */ @@ -140,6 +141,8 @@ export interface ResolvedSessionOptions extends AgentRuntimeOptions { * `session:runtime-resolved` metadata. */ settings?: Settings; + /** Optional injected storage; only consulted when an instance was explicitly requested. */ + authStorage?: FusionAuthStorage; /** * `beforeSpawnSession` and `taskEnv` are inherited from * {@link AgentRuntimeOptions}. Both are forwarded verbatim to @@ -644,25 +647,19 @@ function armDeferredGrokCliFallback(args: { function pickSettingsThenRuntimeModel( settingsModel: ResolvedModelSelection, assignedAgentRuntimeConfig?: Record, -): { provider: string | undefined; modelId: string | undefined } { +): ResolvedModelSelection { // Project/task/global settings are the authoritative model hierarchy. The // assigned durable agent runtime model is only a final compatibility fallback // when the hierarchy produced no complete pair; partial runtime pairs must // never be mixed with settings fields or mask saved project overrides. if (settingsModel.provider && settingsModel.modelId) { - return { - provider: settingsModel.provider, - modelId: settingsModel.modelId, - }; + return settingsModel; } const assignedRuntimeModel = extractRuntimeModel(assignedAgentRuntimeConfig); return hasCompleteRuntimeModel(assignedRuntimeModel) ? assignedRuntimeModel - : { - provider: settingsModel.provider, - modelId: settingsModel.modelId, - }; + : settingsModel; } export function resolveExecutorSessionModel( @@ -670,7 +667,8 @@ export function resolveExecutorSessionModel( taskModelId: string | undefined, settings: Partial | undefined, assignedAgentRuntimeConfig?: Record, -): { provider: string | undefined; modelId: string | undefined } { + taskCredentialInstanceId?: string | null, +): ResolvedModelSelection { if (isTestModeActive(settings)) { return { provider: TEST_MODE_RESOLVED.provider, @@ -682,6 +680,7 @@ export function resolveExecutorSessionModel( { modelProvider: taskModelProvider, modelId: taskModelId, + credentialInstanceId: taskCredentialInstanceId, }, settings, ); @@ -694,7 +693,8 @@ export function resolvePlanningSessionModel( taskPlanningModelId: string | undefined, settings: Partial | undefined, assignedAgentRuntimeConfig?: Record, -): { provider: string | undefined; modelId: string | undefined } { + taskCredentialInstanceId?: string | null, +): ResolvedModelSelection { if (isTestModeActive(settings)) { return { provider: TEST_MODE_RESOLVED.provider, @@ -706,6 +706,7 @@ export function resolvePlanningSessionModel( { planningModelProvider: taskPlanningModelProvider, planningModelId: taskPlanningModelId, + planningCredentialInstanceId: taskCredentialInstanceId, }, settings, ); @@ -730,7 +731,7 @@ export function resolveImplicitPlanningFallbackModel( primaryProvider: string | undefined, primaryModelId: string | undefined, assignedAgentRuntimeConfig?: Record, -): { provider: string | undefined; modelId: string | undefined } { +): ResolvedModelSelection { if (isTestModeActive(settings)) { return { provider: undefined, modelId: undefined }; } @@ -772,7 +773,8 @@ export function resolveValidatorSessionModel( taskValidatorModelId: string | undefined, settings: Partial | undefined, assignedAgentRuntimeConfig?: Record, -): { provider: string | undefined; modelId: string | undefined } { + taskCredentialInstanceId?: string | null, +): ResolvedModelSelection { if (isTestModeActive(settings)) { return { provider: TEST_MODE_RESOLVED.provider, @@ -784,6 +786,7 @@ export function resolveValidatorSessionModel( { validatorModelProvider: taskValidatorModelProvider, validatorModelId: taskValidatorModelId, + validatorCredentialInstanceId: taskCredentialInstanceId, }, settings, ); @@ -797,6 +800,7 @@ export function resolveHeartbeatSessionModels( ): { defaultProvider: string | undefined; defaultModelId: string | undefined; + credentialInstanceId?: string; fallbackProvider: string | undefined; fallbackModelId: string | undefined; } { @@ -816,13 +820,14 @@ export function resolveHeartbeatSessionModels( FNXC:AgentHeartbeat 2026-07-14-16:13: Durable-agent heartbeats must use the complete model assigned to that agent. Shared project execution defaults are only a fallback for an absent or incomplete assignment; otherwise one broken project override can park every heterogeneous agent under the same unrelated provider. */ - const resolvedModel = hasCompleteRuntimeModel(assignedRuntimeModel) + const resolvedModel: ResolvedModelSelection = hasCompleteRuntimeModel(assignedRuntimeModel) ? assignedRuntimeModel : pickSettingsThenRuntimeModel(executionSettingsModel, assignedAgentRuntimeConfig); return { defaultProvider: resolvedModel.provider, defaultModelId: resolvedModel.modelId, + ...(resolvedModel.credentialInstanceId ? { credentialInstanceId: resolvedModel.credentialInstanceId } : {}), fallbackProvider: executorFallbackModel.provider, fallbackModelId: executorFallbackModel.modelId, }; @@ -831,8 +836,8 @@ export function resolveHeartbeatSessionModels( export function resolveMergerSessionModel( settings: Partial | undefined, assignedAgentRuntimeConfig?: Record, - task?: { mergerModelProvider?: string | null; mergerModelId?: string | null }, -): { provider: string | undefined; modelId: string | undefined } { + task?: { mergerModelProvider?: string | null; mergerModelId?: string | null; mergerCredentialInstanceId?: string | null }, +): ResolvedModelSelection { if (isTestModeActive(settings)) { return { provider: TEST_MODE_RESOLVED.provider, @@ -848,7 +853,7 @@ export function resolveMergerSessionModel( */ /* FNXC:Settings-MergerModel 2026-07-16-12:00: task pair → settings → global/default; partial task pairs inherit settings. */ const mergerModel = task?.mergerModelProvider && task?.mergerModelId - ? { provider: task.mergerModelProvider, modelId: task.mergerModelId } + ? { provider: task.mergerModelProvider, modelId: task.mergerModelId, credentialInstanceId: task.mergerCredentialInstanceId ?? undefined } : resolveMergerSettingsModel(settings); return pickSettingsThenRuntimeModel(mergerModel, assignedAgentRuntimeConfig); } @@ -867,7 +872,23 @@ export function resolveMergerSessionModel( export async function createResolvedAgentSession( options: ResolvedSessionOptions, ): Promise { - const { sessionPurpose, pluginRunner, runtimeHint, runAuditor, settings, ...runtimeOptionsRaw } = options; + const { sessionPurpose, pluginRunner, runtimeHint, runAuditor, settings, authStorage: injectedAuthStorage, credentialInstanceId: requestedCredentialInstanceId, ...runtimeOptionsRaw } = options; + let credentialResolution: ReturnType | undefined; + if (requestedCredentialInstanceId) { + try { + 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-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. + */ + if (credentialResolution?.missing) sessionLog.warn(`[${sessionPurpose}] requested credential instance is missing; using provider default`); const skillNamesFromSelection = extractSkillNamesFromSelection(runtimeOptionsRaw.skillSelection); const mergedSkillNames = runtimeOptionsRaw.skills && runtimeOptionsRaw.skills.length > 0 @@ -891,6 +912,10 @@ export async function createResolvedAgentSession( ? runtimeOptionsRaw.toolOutputMaxChars : resolveAgentToolOutputMaxChars(settings ?? {}), ...(mergedSkillNames.length > 0 ? { skills: mergedSkillNames } : {}), + ...(credentialResolution ? { + resolvedCredentialInstance: credentialResolution.ref, + credentialInstanceId: credentialResolution.requestedInstanceId, + } : {}), }; // FNXC:McpConfig 2026-06-25-22:06: // createResolvedAgentSession is the common lane helper for executor, reviewer, validator, workflow model-node, summarization, and merger-adjacent paths that pass MCP through this seam. Preserve `mcpServers` verbatim here; runtime-resolution/pi own support-gated forwarding and content-free skip logging. @@ -1044,6 +1069,12 @@ export async function createResolvedAgentSession( ...(grokFallbackDeferral.dropped ? { grokCliFallbackDropped: true } : {}), ...(deferredGrokFallback ? { grokCliFallbackDeferred: true } : {}), ...(noModelResolved ? { noModelResolved: true, runtimeBuiltInFallbackModel } : {}), + ...(credentialResolution?.ref.instanceId ? { credentialInstanceId: credentialResolution.ref.instanceId } : {}), + ...(credentialResolution?.missing ? { + credentialInstanceMissing: true, + requestedCredentialInstanceId: credentialResolution.requestedInstanceId, + resolvedCredentialInstanceId: credentialResolution.ref.instanceId, + } : {}), /* FNXC:FusionToolBridgeDiagnostics 2026-07-20-08:00: Plugin bridge failures are session-visible, but they also need one durable diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index bff568caa6..6006f1e2b6 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -17,6 +17,7 @@ import { isReservedAuthStorageKey, isStoredAuthCredential, isValidProviderId, + isValidProviderInstanceId, parseProviderInstanceKey, } from "@fusion/core"; import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent"; @@ -40,7 +41,7 @@ export interface FusionAuthStorage { set(provider: string, credential: StoredCredential): Promise; remove(provider: string): Promise; logout(provider: string): Promise; - getApiKey(provider: string): Promise; + getApiKey(provider: string, instance?: ProviderInstanceRef): Promise; getOAuthProviders(): Array<{ id: string; name: string }>; login(provider: string, callbacks: unknown): Promise; modify(provider: string, fn: (current: StoredCredential | undefined) => Promise): Promise; @@ -213,7 +214,9 @@ class FusionFileAuthStorage implements FusionAuthStorage { async removeInstance(ref: ProviderInstanceRef): Promise { this.assertRef(ref); await this.removeRef(ref); } async logout(provider: string): Promise { await this.remove(provider); } async setDefaultInstance(ref: ProviderInstanceRef): Promise { this.assertRef(ref); await this.withLock(async current => { if (!this.credential(ref, current)) throw new Error("Cannot set default for a missing credential instance"); const defaults = { ...readDefaultInstanceMap(current), [ref.providerId]: ref.instanceId }; current.__fusionDefaultInstances = defaults; return { result: undefined, changed: true }; }); } - async getApiKey(provider: string): Promise { return resolveStoredCredentialApiKey(provider, this.get(provider)); } + async getApiKey(provider: string, instance?: ProviderInstanceRef): Promise { + return resolveStoredCredentialApiKey(provider, instance ? this.getInstance(instance) : this.get(provider)); + } async modify(provider: string, fn: (current: StoredCredential | undefined) => Promise): Promise { this.assertRefFromKey(provider); return this.withLock(async current => { @@ -237,27 +240,79 @@ class FusionFileAuthStorage implements FusionAuthStorage { } } -export function createFusionCredentialStore(authStorage: FusionAuthStorage): CredentialStore { +export class CredentialInstanceResolutionError extends Error { + constructor(providerId: string, instanceId: string) { + super(`Credential instance "${instanceId}" for provider "${providerId}" was not found and the provider has no default instance`); + this.name = "CredentialInstanceResolutionError"; + } +} + +export type CredentialInstanceResolution = { + ref: ProviderInstanceRef; + requestedInstanceId: string; + missing: boolean; +}; + +/* +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. +*/ +export function resolveCredentialInstanceRef( + authStorage: FusionAuthStorage, + providerId: string, + requestedInstanceId: string, +): CredentialInstanceResolution { + const requested = { providerId, instanceId: requestedInstanceId }; + if (isValidProviderInstanceId(requestedInstanceId) && authStorage.getInstance(requested)) { + return { ref: requested, requestedInstanceId, missing: false }; + } + const ref = authStorage.getDefaultInstance(providerId); + if (!ref) throw new CredentialInstanceResolutionError(providerId, requestedInstanceId); + return { ref, requestedInstanceId, missing: true }; +} + +/* +FNXC:ProviderAuth 2026-08-01-08:10: +The resolved ref applies only to its provider. Applying provider A's selection to a fallback provider B would silently spend B credentials under the wrong operator intent; missing named instances instead retain the audited provider default. +*/ +export function createFusionCredentialStore(authStorage: FusionAuthStorage, resolvedCredentialInstance?: ProviderInstanceRef): CredentialStore { + const scopedRefFor = (providerId: string): ProviderInstanceRef | undefined => { + const normalizedProviderId = providerId === ANTHROPIC_PROVIDER_ID + ? ANTHROPIC_PROVIDER_ID + : providerId; + return resolvedCredentialInstance?.providerId === normalizedProviderId + ? resolvedCredentialInstance + : undefined; + }; return { /* FNXC:ProviderAuth 2026-07-17-06:30: pi >=0.80.8 moved session request auth from `ModelRegistry.getApiKeyAndHeaders` (which called fusion's `getApiKey(provider)`) to `ModelRuntime.getAuth` -> pi-ai `resolveProviderAuth`, which reads the credential store directly (`credentials.read(provider.id)`) and, for an OAuth credential, refreshes it ITSELF via `credentials.modify(provider.id, ...)`. That refresh path is broken for Anthropic: fusion persists the subscription login under `anthropic-subscription` (there is NO raw `anthropic` row), so `modify("anthropic")` reads `current === undefined`, the refresh callback bails, and `resolveStoredOAuth` returns undefined -> the task fails with `Provider is not configured: anthropic` (then falls back). The status card still shows "connected" because status uses a different path (hasVisibleAnthropicCredential). Fix: resolve Anthropic auth through fusion's `getApiKey("anthropic")`, the battle-tested path that already handles token refresh + the raw-key/legacy-oauth/subscription/fallback precedence (see resolveAnthropicRuntimeApiKey), and hand pi-ai a ready-to-use api_key credential. pi-ai's anthropic-messages layer routes by token prefix — `sk-ant-oat*` -> OAuth Bearer + Claude Code identity headers, otherwise x-api-key — so a subscription OAuth token still runs as OAuth, and returning it as `api_key` deliberately bypasses pi-ai's own (broken-for-us) OAuth refresh-via-modify. Other OAuth providers (openai-codex, github-copilot) are stored under their own provider id, so read/modify share an id and pi-ai's refresh works — only Anthropic needs this indirection. */ read: async (providerId) => { + const scopedRef = scopedRefFor(providerId); if (providerId === ANTHROPIC_PROVIDER_ID) { - const token = await authStorage.getApiKey(ANTHROPIC_PROVIDER_ID); + // Preserve Anthropic's refresh-aware getApiKey indirection for scoped instances too. + const token = await authStorage.getApiKey(ANTHROPIC_PROVIDER_ID, scopedRef); return token ? ({ type: "api_key", key: token } as Credential) : undefined; } - return authStorage.get(providerId) as Credential | undefined; + return (scopedRef ? authStorage.getInstance(scopedRef) : authStorage.get(providerId)) as Credential | undefined; }, list: async () => authStorage.list().flatMap((providerId): CredentialInfo[] => { - const credential = authStorage.get(providerId); - return credential?.type === "api_key" || credential?.type === "oauth" - ? [{ providerId, type: credential.type }] - : []; + const credential = scopedRefFor(providerId) ? authStorage.getInstance(scopedRefFor(providerId)!) : authStorage.get(providerId); + return credential?.type === "api_key" || credential?.type === "oauth" ? [{ providerId, type: credential.type }] : []; }), - modify: async (providerId, fn) => authStorage.modify(providerId, async (current) => fn(current as Credential | undefined) as Promise) as Promise, - delete: async (providerId) => { await authStorage.remove(providerId); }, + modify: async (providerId, fn) => { + const scopedRef = scopedRefFor(providerId); + if (!scopedRef) return authStorage.modify(providerId, async current => fn(current as Credential | undefined) as Promise) as Promise; + const next = await fn(authStorage.getInstance(scopedRef) as Credential | undefined); + if (next) await authStorage.setInstance(scopedRef, next as StoredCredential); + return next; + }, + delete: async (providerId) => { + const scopedRef = scopedRefFor(providerId); + if (scopedRef) await authStorage.removeInstance(scopedRef); else await authStorage.remove(providerId); + }, }; } @@ -557,9 +612,9 @@ export type FusionModelRegistry = ModelRegistry & { readonly modelRuntime: Model * ModelRuntime. Keep Fusion's file-backed, locked credential adapter as the runtime * CredentialStore so FN-7646's per-provider read-modify-merge guarantee survives. */ -export async function createFusionModelRegistry(authStorage: FusionAuthStorage, home?: string): Promise { +export async function createFusionModelRegistry(authStorage: FusionAuthStorage, home?: string, resolvedCredentialInstance?: ProviderInstanceRef): Promise { const modelRuntime = await ModelRuntime.create({ - credentials: createFusionCredentialStore(authStorage), + credentials: createFusionCredentialStore(authStorage, resolvedCredentialInstance), modelsPath: getModelRegistryModelsPath(home), }); authStorage.setModelRuntime(modelRuntime); @@ -1110,9 +1165,16 @@ export function createFusionAuthStorage(): FusionAuthStorage { } if (prop === "getApiKey") { - return async (provider: string) => { + return async (provider: string, instance?: ProviderInstanceRef) => { if (!parseProviderInstanceKey(provider)) return undefined; await supplementalHydration; + if (instance) { + const credential = target.getInstance(instance); + if (!credential) return undefined; + const refreshed = await refreshOAuthCredential(instance.providerId, credential); + if (refreshed && refreshed !== credential) await target.setInstance(instance, refreshed); + return resolveStoredCredentialApiKey(provider, refreshed ?? credential); + } if (provider === ANTHROPIC_PROVIDER_ID) { return resolveAnthropicRuntimeApiKey(); } diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index abe7db6870..09711815ee 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -8735,6 +8735,7 @@ export class TaskExecutor { ), taskValidatorProvider: detail.validatorModelProvider, taskValidatorModelId: detail.validatorModelId, + taskValidatorCredentialInstanceId: detail.validatorCredentialInstanceId, projectValidatorProvider: settings.validatorProvider, projectValidatorModelId: settings.validatorModelId, projectValidatorFallbackProvider: settings.validatorFallbackProvider, @@ -14187,12 +14188,14 @@ export class TaskExecutor { Override column agents own initial session model selection as well as mid-flight re-resolution. Ignore task-level modelProvider/modelId before resolveExecutorSessionModel so pre-existing task model pairs cannot run the column-agent identity on the task model. */ const overrideColumnGovernsInitialSession = columnAgentSeam?.mode === "override"; - const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel( + const executorSessionModel = resolveExecutorSessionModel( overrideColumnGovernsInitialSession ? undefined : detail.modelProvider, overrideColumnGovernsInitialSession ? undefined : detail.modelId, settings, (identityAgent?.runtimeConfig ?? undefined) as Record | undefined, + overrideColumnGovernsInitialSession ? undefined : detail.credentialInstanceId, ); + const { provider: executorProvider, modelId: executorModelId } = executorSessionModel; const { provider: executorFallbackProvider, modelId: executorFallbackModelId } = resolveExecutorFallbackModel(settings); const executorSessionThinkingSource = this.graphSeamThinkingLevel.get(task.id) ?? detail.thinkingLevel; const executorThinkingLevel = resolveExecutorThinkingLevel(executorSessionThinkingSource, settings); @@ -14293,6 +14296,7 @@ export class TaskExecutor { onToolEnd: agentLogger.onToolEnd, defaultProvider: executorProvider, defaultModelId: executorModelId, + ...(executorSessionModel.credentialInstanceId ? { credentialInstanceId: executorSessionModel.credentialInstanceId } : {}), fallbackProvider: executorFallbackProvider, fallbackModelId: executorFallbackModelId, fallbackThinkingLevel: executorFallbackThinkingLevel, @@ -14737,6 +14741,7 @@ export class TaskExecutor { onToolEnd: agentLogger.onToolEnd, defaultProvider: executorProvider, defaultModelId: executorModelId, + ...(executorSessionModel.credentialInstanceId ? { credentialInstanceId: executorSessionModel.credentialInstanceId } : {}), fallbackProvider: executorFallbackProvider, fallbackModelId: executorFallbackModelId, fallbackThinkingLevel: executorFallbackThinkingLevel, @@ -17713,12 +17718,14 @@ export class TaskExecutor { // Resolve model using the executor's model hierarchy const assignedRuntimeConfig = await this.getAssignedAgentRuntimeConfig(task.assignedAgentId); - const { provider: executorProvider, modelId: executorModelId } = resolveExecutorSessionModel( + const executorSessionModel = resolveExecutorSessionModel( task.modelProvider, task.modelId, settings, assignedRuntimeConfig, + task.credentialInstanceId, ); + const { provider: executorProvider, modelId: executorModelId } = executorSessionModel; const executorFallback = resolveExecutorFallbackModel(settings); @@ -17750,6 +17757,7 @@ Do not refactor, rename broadly, or make opportunistic improvements. onToolEnd: logger.onToolEnd, defaultProvider: executorProvider, defaultModelId: executorModelId, + ...(executorSessionModel.credentialInstanceId ? { credentialInstanceId: executorSessionModel.credentialInstanceId } : {}), fallbackProvider: executorFallback.provider, fallbackModelId: executorFallback.modelId, fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(task.thinkingLevel, settings), @@ -18743,16 +18751,20 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB task.validatorModelId, settings, assignedRuntimeConfig, + task.validatorCredentialInstanceId, ) : resolveExecutorSessionModel( task.modelProvider, task.modelId, settings, assignedRuntimeConfig, + task.credentialInstanceId, ); const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId); const primaryProvider = useOverride ? workflowStep.modelProvider : laneModel.provider; const primaryModelId = useOverride ? workflowStep.modelId : laneModel.modelId; + // FNXC:ProviderAuth 2026-08-01-08:39: A workflow-step model override has no paired instance selection, so only the resolved primary task lane may carry its requested credential instance. Fallback attempts must retain their provider-default behavior rather than inheriting a primary-provider identity. + const primaryCredentialInstanceId = useOverride ? undefined : laneModel.credentialInstanceId; const workflowFallback = isReviewTypeWorkflowStep ? resolveValidatorFallbackModel(settings) @@ -18932,6 +18944,9 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB tools: toolMode, defaultProvider: provider, defaultModelId: modelId, + ...(attemptLabel !== "fallback" && primaryCredentialInstanceId + ? { credentialInstanceId: primaryCredentialInstanceId } + : {}), fallbackProvider: workflowFallback.provider, fallbackModelId: workflowFallback.modelId, fallbackThinkingLevel: workflowStepFallbackThinkingLevel, @@ -22044,8 +22059,13 @@ Child agent: ${agent.id} (${name})`; // Resolve executor model via canonical lane hierarchy so child agents // honor project executionProvider/executionModelId overrides (parity // with main executor at the top of agentWork()). - const { provider: childExecutorProvider, modelId: childExecutorModelId } = - resolveExecutorSessionModel(undefined, undefined, settings, agent.runtimeConfig as Record | undefined); + const childExecutorSessionModel = resolveExecutorSessionModel( + undefined, + undefined, + settings, + agent.runtimeConfig as Record | undefined, + ); + const { provider: childExecutorProvider, modelId: childExecutorModelId } = childExecutorSessionModel; const childExecutorFallback = resolveExecutorFallbackModel(settings); @@ -22059,6 +22079,7 @@ Child agent: ${agent.id} (${name})`; tools: "coding", defaultProvider: childExecutorProvider, defaultModelId: childExecutorModelId, + ...(childExecutorSessionModel.credentialInstanceId ? { credentialInstanceId: childExecutorSessionModel.credentialInstanceId } : {}), fallbackProvider: childExecutorFallback.provider, fallbackModelId: childExecutorFallback.modelId, fallbackThinkingLevel: resolveExecutorFallbackThinkingLevel(undefined, settings), diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index 2fa4ab4d31..f15c7c0151 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -439,6 +439,7 @@ function makeMutatingAgent(store: TaskStore, settings: Settings, taskId: string, onToolEnd: logger.onToolEnd, defaultProvider: model.provider, defaultModelId: model.modelId, + ...(model.credentialInstanceId ? { credentialInstanceId: model.credentialInstanceId } : {}), fallbackProvider: mergerFallbackModel.provider, fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, task?.mergerThinkingLevel), @@ -508,6 +509,7 @@ function makeReviewAgent(store: TaskStore, settings: Settings, taskId: string, o onToolEnd: logger.onToolEnd, defaultProvider: model.provider, defaultModelId: model.modelId, + ...(model.credentialInstanceId ? { credentialInstanceId: model.credentialInstanceId } : {}), fallbackProvider: mergerFallbackModel.provider, fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, task?.mergerThinkingLevel), diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 28e7027c99..c293bcdaec 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -1182,6 +1182,7 @@ Do not refactor, rename broadly, or make opportunistic improvements. onToolEnd: logger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, + ...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}), fallbackProvider: mergerFallbackModel.provider, fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel), @@ -2449,6 +2450,7 @@ ${fileList} onToolEnd: agentLogger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, + ...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}), fallbackProvider: mergerFallbackModel.provider, fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel), @@ -2873,6 +2875,7 @@ ${fileList} onToolEnd: agentLogger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, + ...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}), fallbackProvider: mergerFallbackModel.provider, fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel), @@ -5925,6 +5928,7 @@ You are assisting with a paused \`git pull --rebase\`. onToolEnd: agentLogger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, + ...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}), fallbackProvider: mergerFallbackModel.provider, fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel), @@ -10923,6 +10927,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo onToolEnd: agentLogger.onToolEnd, defaultProvider: mergerSessionModel.provider, defaultModelId: mergerSessionModel.modelId, + ...(mergerSessionModel.credentialInstanceId ? { credentialInstanceId: mergerSessionModel.credentialInstanceId } : {}), fallbackProvider: mergerFallbackModel.provider, fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, mergerTask?.mergerThinkingLevel), diff --git a/packages/engine/src/mission-execution-loop.ts b/packages/engine/src/mission-execution-loop.ts index bf21e8a0c2..0e3d0851e2 100644 --- a/packages/engine/src/mission-execution-loop.ts +++ b/packages/engine/src/mission-execution-loop.ts @@ -802,6 +802,7 @@ export class MissionExecutionLoop extends EventEmitter { tools: "readonly", defaultProvider: validationSessionModel.provider, defaultModelId: validationSessionModel.modelId, + ...(validationSessionModel.credentialInstanceId ? { credentialInstanceId: validationSessionModel.credentialInstanceId } : {}), fallbackProvider: settings?.fallbackProvider, fallbackModelId: settings?.fallbackModelId, defaultThinkingLevel: "medium", @@ -1077,12 +1078,13 @@ export class MissionExecutionLoop extends EventEmitter { task: Awaited> | null, settings: Partial | undefined, assignedAgentRuntimeConfig?: Record, - ): { provider: string | undefined; modelId: string | undefined } { + ): { provider?: string; modelId?: string; credentialInstanceId?: string } { return resolveValidatorSessionModel( task?.validatorModelProvider, task?.validatorModelId, settings, assignedAgentRuntimeConfig, + task?.validatorCredentialInstanceId, ); } diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 81d29a3d0b..b6e569c372 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -58,6 +58,7 @@ import type { AgentPermissionPolicyActionCategory, PermanentAgentActionCategory, PermanentAgentGatingContext, + ProviderInstanceRef, ResolvedMcpServerDefinition, } from "@fusion/core"; import { @@ -1064,6 +1065,10 @@ export interface AgentOptions { defaultProvider?: string; /** Default model ID within the provider (e.g. "claude-sonnet-4-5"). Used with `defaultProvider`. */ defaultModelId?: string; + /** Concrete session-scoped credential instance, resolved by agent-session-helpers. */ + resolvedCredentialInstance?: ProviderInstanceRef; + /** Informational requested credential instance id. */ + credentialInstanceId?: string; /** Optional fallback model provider used when the primary selected model hits * a retryable provider-side failure such as rate limiting or overload. */ fallbackProvider?: string; @@ -2299,7 +2304,7 @@ export async function createFnAgent(options: AgentOptions): Promise logMcpForwardingSkipped({ runtimeId: "pi", provider: options.defaultProvider, skippedCount: requestedMcpServers.length, lane: "createFnAgent" }); } const authStorage = createFusionAuthStorage(); - const modelRegistry = await createFusionModelRegistry(authStorage); + const modelRegistry = await createFusionModelRegistry(authStorage, undefined, options.resolvedCredentialInstance); const modelRuntime = modelRegistry.modelRuntime; // Resolve the project root early so extension providers, skill discovery, diff --git a/packages/engine/src/pr-response-run-ops.ts b/packages/engine/src/pr-response-run-ops.ts index e0d070d022..7140544b26 100644 --- a/packages/engine/src/pr-response-run-ops.ts +++ b/packages/engine/src/pr-response-run-ops.ts @@ -132,6 +132,7 @@ export function makePrResponseAgentRunner( }, defaultProvider: model.provider, defaultModelId: model.modelId, + ...(model.credentialInstanceId ? { credentialInstanceId: model.credentialInstanceId } : {}), fallbackProvider: mergerFallbackModel.provider, fallbackModelId: mergerFallbackModel.modelId, fallbackThinkingLevel: resolveMergerFallbackThinkingLevel(settings, task?.mergerThinkingLevel), diff --git a/packages/engine/src/reviewer.ts b/packages/engine/src/reviewer.ts index 538c16e1ee..c371704e0e 100644 --- a/packages/engine/src/reviewer.ts +++ b/packages/engine/src/reviewer.ts @@ -91,6 +91,8 @@ export interface ReviewOptions { taskValidatorProvider?: string; /** Task-level validator model ID override. When both provider and modelId are set, takes precedence over project/global lanes. */ taskValidatorModelId?: string; + /** Credential instance paired with the task-level validator model selection. */ + taskValidatorCredentialInstanceId?: string; /** Project-level validator model provider override. Takes precedence over global validator lane. */ projectValidatorProvider?: string; /** Project-level validator model ID override. Takes precedence over global validator lane. */ @@ -302,6 +304,8 @@ export async function reviewStep( options.taskValidatorProvider, options.taskValidatorModelId, reviewerModelSettings, + undefined, + options.taskValidatorCredentialInstanceId, ); const validatorProvider = reviewerModel.provider; const validatorModelId = reviewerModel.modelId; @@ -500,6 +504,9 @@ export async function reviewStep( onToolEnd: agentLogger?.onToolEnd, defaultProvider: overrides?.forceProvider ?? validatorProvider, defaultModelId: overrides?.forceModelId ?? validatorModelId, + ...(!overrides?.forceProvider && !overrides?.forceModelId && reviewerModel.credentialInstanceId + ? { credentialInstanceId: reviewerModel.credentialInstanceId } + : {}), fallbackProvider: validatorFallbackProvider, fallbackModelId: validatorFallbackModelId, fallbackThinkingLevel: options.fallbackThinkingLevel diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index e9fe8c0fea..34785901e5 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -2684,12 +2684,14 @@ export class TriageProcessor { task.planningModelId, settings, assignedAgent?.runtimeConfig, + task.planningCredentialInstanceId, ); activePlanningProvider = planningModel.provider; const planningSessionModelOptions = { defaultProvider: planningModel.provider, defaultModelId: planningModel.modelId, + ...(planningModel.credentialInstanceId ? { credentialInstanceId: planningModel.credentialInstanceId } : {}), }; /*