diff --git a/doc/connections/AI-CONNECTION-ROUTERS.md b/doc/connections/AI-CONNECTION-ROUTERS.md index f95f15077de..645cc9544d5 100644 --- a/doc/connections/AI-CONNECTION-ROUTERS.md +++ b/doc/connections/AI-CONNECTION-ROUTERS.md @@ -34,6 +34,11 @@ eligible member; otherwise the operator must explicitly reset the session. Credential `ai_session_epoch` changes on reconnect or manual rotation. Only verified runtime refresh write-back preserves it. Session fingerprints use the epoch while authentication failure attribution still uses the token generation. +Adopting a valid account preserves a session only when the complete effective +configuration matches a prior fingerprint. Core can bridge binding-only agent +revisions (up to 20) and unchanged legacy token identities at epoch zero. Changes +to other settings, explicit resets, and credential replacement retain their +existing reset behavior; no fingerprint category is exempted. Native recovery retains concrete routing evidence and can finish after the router flag or plugin is disabled or uninstalled; it revalidates underlying account access and never makes a new allocation. diff --git a/server/src/__tests__/ai-connection-session.test.ts b/server/src/__tests__/ai-connection-session.test.ts new file mode 100644 index 00000000000..9fee2355fce --- /dev/null +++ b/server/src/__tests__/ai-connection-session.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible, aiConnectionOnlyConfigRevision, aiConnectionRevisionMetadata } from "../services/ai-connection-session.js"; +import { buildEffectiveRunSessionConfigMetadata, resolveTaskSessionConfigFreshness } from "../services/heartbeat.js"; + +const binding = { mode: "shared" as const, provider: "openai" as const, method: "subscription" as const, connectionId: "account", grantId: "grant" }; +const pool = { mode: "router", connectionId: "pool" }; +const sessionIdentity = "grant:alice:0", credentialIdentity = "grant:alice:token"; +const config = { provider: "codex", model: "gpt-5.6-sol", env: { OPENAI_API_KEY: "fixture-token" }, managedAiConnection: { ...binding, identity: credentialIdentity, sessionIdentity } }; +const base = { adapterType: "paperclip_runner", effectiveAdapterConfig: config, agentRuntimeConfig: { aiConnection: pool }, agentConfigRevision: null as unknown, issueOverrides: null as unknown, workspaceConfig: {}, environment: {}, environmentEnv: null, projectEnv: null, routineEnv: null, runtimeSkills: [] }; +type MetadataInput = Parameters[0]; +type CompatibilityInput = Parameters[0]; +const revision = (id: string, beforeBinding: unknown, afterBinding: unknown, extra = {}) => ({ + id, changedKeys: ["runtimeConfig"], createdAt: new Date(`2026-10-02T00:00:0${id.slice(-1)}Z`), + beforeConfig: { name: "Agent", runtimeConfig: { aiConnection: beforeBinding }, adapterConfig: { model: "gpt-5.6-sol" } }, + afterConfig: { name: "Agent", runtimeConfig: { aiConnection: afterBinding }, adapterConfig: { model: "gpt-5.6-sol" }, ...extra }, +}); + +async function decision(previous: Partial, next: Partial = {}, compatibility: Partial = {}, wakeResetReason?: string) { + const before = await buildEffectiveRunSessionConfigMetadata({ ...base, ...previous }); + const afterInput = { ...base, ...next }; + const after = await buildEffectiveRunSessionConfigMetadata(afterInput); + const candidates = aiConnectionSessionCompatibilityInputs({ ...afterInput, originalIssueOverrides: null, binding, router: true, storedIdentity: sessionIdentity, sessionIdentity, credentialIdentity, revisions: [], ...compatibility }); + const alternatives = await Promise.all(candidates.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...afterInput, ...candidate }))); + return resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: String(afterInput.effectiveAdapterConfig.model), taskSessionParams: { __paperclipConfiguredModel: "gpt-5.6-sol", __paperclipConfigFingerprint: before.fingerprint, __paperclipConfigFingerprintVersion: before.version, __paperclipConfigCategoryFingerprints: before.categoryFingerprints }, configMetadata: after, compatibleConfigMetadata: alternatives, wakeResetReason }); +} + +describe("AI pool session adoption", () => { + it.each([binding, { ...binding, mode: "delegated" }, { mode: "responsible_user", provider: "openai", method: "api_key" }])("preserves the exact existing configuration through a compatible binding change: %j", async aiConnection => { + expect((await decision({ agentRuntimeConfig: { aiConnection } })).reset).toBe(false); + }); + it("bridges only account-binding edits in revision history", async () => { + const first = revision("revision-1", null, binding); + const adoption = revision("revision-2", binding, pool); + expect(aiConnectionOnlyConfigRevision(adoption)).toBe(true); + expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentConfigRevision: aiConnectionRevisionMetadata(adoption) }, { revisions: [adoption, first] })).reset).toBe(false); + }); + it("handles consecutive binding edits without erasing another configuration revision", async () => { + const first = revision("revision-1", null, binding); + const adoption = revision("revision-2", binding, { mode: "router", connectionId: "previous-pool" }); + const switchPool = revision("revision-3", { mode: "router", connectionId: "previous-pool" }, pool); + expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentConfigRevision: aiConnectionRevisionMetadata(switchPool) }, { revisions: [switchPool, adoption, first] })).reset).toBe(false); + }); + it("retains resets when the same edit also changes unrelated configuration", async () => { + const first = revision("revision-1", null, binding); + const edit = revision("revision-2", binding, pool, { name: "Renamed agent" }); + expect(aiConnectionOnlyConfigRevision(edit)).toBe(false); + expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentConfigRevision: aiConnectionRevisionMetadata(edit) }, { revisions: [edit, first] })).reset).toBe(true); + }); + it("does not bridge a stale revision over a newer runtime settings change", async () => { + const first = revision("revision-1", null, binding); + const adoption = revision("revision-2", binding, pool); + expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, agentConfigRevision: aiConnectionRevisionMetadata(first) }, { agentRuntimeConfig: { aiConnection: pool, heartbeat: { enabled: false } }, agentConfigRevision: aiConnectionRevisionMetadata(adoption) }, { revisions: [adoption, first] })).reset).toBe(true); + }); + it.each([ + { effectiveAdapterConfig: { ...config, model: "gpt-5.6-terra" } }, + { effectiveAdapterConfig: { ...config, provider: "acpx", acpxAgent: "codex" } }, + { effectiveAdapterConfig: { ...config, env: { ...config.env, OTHER: "changed" } } }, + { agentRuntimeConfig: { aiConnection: pool, heartbeat: { maxConcurrentRuns: 2 } } }, + { workspaceConfig: { effectiveMode: "isolated_workspace" } }, + ])("preserves normal resets for effective settings changes: %j", async next => { + expect((await decision({ agentRuntimeConfig: { aiConnection: binding } }, next)).reset).toBe(true); + }); + it("preserves an explicit session reset even when a compatibility fingerprint matches", async () => { + expect((await decision({ agentRuntimeConfig: { aiConnection: binding } }, {}, {}, "explicit session reset")).reset).toBe(true); + }); + it("migrates a legacy token fingerprint only for the unchanged credential at epoch zero", async () => { + const managed = { ...config.managedAiConnection } as Record; delete managed.sessionIdentity; + expect((await decision({ agentRuntimeConfig: { aiConnection: binding }, effectiveAdapterConfig: { ...config, managedAiConnection: managed } }, {}, { storedIdentity: credentialIdentity })).reset).toBe(false); + }); + it("does not treat a changed token or a manual replacement of the same bytes as legacy-compatible", () => { + expect(managedAiSessionIdentityCompatible("grant:alice:old-token", sessionIdentity, credentialIdentity)).toBe(false); + expect(managedAiSessionIdentityCompatible(credentialIdentity, "grant:alice:1", credentialIdentity)).toBe(false); + expect(managedAiSessionIdentityCompatible(sessionIdentity, "grant:alice:1", credentialIdentity)).toBe(false); + expect(managedAiSessionIdentityCompatible(sessionIdentity, sessionIdentity, "grant:alice:refreshed-token")).toBe(true); + }); + it("rejects compatibility candidates for a different account or responsible user", () => { + const input = { ...base, originalIssueOverrides: null, binding, router: true, sessionIdentity, credentialIdentity, revisions: [] }; + expect(aiConnectionSessionCompatibilityInputs({ ...input, storedIdentity: "other-grant:alice:0" })).toEqual([]); + expect(aiConnectionSessionCompatibilityInputs({ ...input, storedIdentity: "grant:bob:0" })).toEqual([]); + }); +}); diff --git a/server/src/services/ai-connection-session.ts b/server/src/services/ai-connection-session.ts new file mode 100644 index 00000000000..f0ff8248d39 --- /dev/null +++ b/server/src/services/ai-connection-session.ts @@ -0,0 +1,90 @@ +import { isDeepStrictEqual } from "node:util"; +import type { AiConnectionBinding } from "@paperclipai/shared"; + +type ConfigRevision = { + id: string; + changedKeys: string[]; + createdAt: Date; + beforeConfig: Record; + afterConfig: Record; +}; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record : {}; +} + +export function managedAiSessionIdentityCompatible( + stored: unknown, + sessionIdentity: string, + credentialIdentity: string, +): boolean { + // Legacy token identities can migrate only before any manual replacement. + // Replacing even the same credential bytes increments the epoch. + return stored === sessionIdentity || (sessionIdentity.endsWith(":0") && stored === credentialIdentity); +} + +export function aiConnectionOnlyConfigRevision(revision: ConfigRevision): boolean { + const withoutBinding = (snapshot: Record) => { + const runtimeConfig = { ...record(snapshot.runtimeConfig) }; + delete runtimeConfig.aiConnection; + return { ...snapshot, runtimeConfig }; + }; + return !isDeepStrictEqual(record(revision.beforeConfig.runtimeConfig).aiConnection, record(revision.afterConfig.runtimeConfig).aiConnection) + && isDeepStrictEqual(withoutBinding(revision.beforeConfig), withoutBinding(revision.afterConfig)); +} + +export function aiConnectionRevisionMetadata(revision: ConfigRevision | undefined) { + return revision ? { id: revision.id, changedKeys: revision.changedKeys, configRevisionAt: revision.createdAt.toISOString() } : null; +} + +/** Exact alternative fingerprints, never a category-level exemption. The caller + * has already authorized the selected account and prepared its credential. */ +export function aiConnectionSessionCompatibilityInputs(input: { + effectiveAdapterConfig: Record; + agentRuntimeConfig: unknown; + agentConfigRevision: unknown; + issueOverrides: unknown; + originalIssueOverrides: unknown; + binding: AiConnectionBinding; + router: boolean; + storedIdentity: unknown; + sessionIdentity: string; + credentialIdentity: string; + revisions: readonly ConfigRevision[]; +}) { + if (!managedAiSessionIdentityCompatible(input.storedIdentity, input.sessionIdentity, input.credentialIdentity)) return []; + const configs = [input.effectiveAdapterConfig]; + if (input.storedIdentity === input.credentialIdentity && input.sessionIdentity.endsWith(":0")) { + const managed = { ...record(input.effectiveAdapterConfig.managedAiConnection) }; + delete managed.sessionIdentity; + managed.identity = input.credentialIdentity; + configs.push({ ...input.effectiveAdapterConfig, managedAiConnection: managed }); + } + const variants = [{ agentRuntimeConfig: input.agentRuntimeConfig, agentConfigRevision: input.agentConfigRevision }]; + if (input.router) { + // Also support bindings saved before revision history was introduced, and + // responsible-user defaults that resolve to this same authorized grant. + const bindings = [input.binding, ...("grantId" in input.binding ? [ + { ...input.binding, mode: "shared" }, { ...input.binding, mode: "delegated" }, + ] : []), ...(["subscription", "api_key"] as const).map(method => ({ mode: "responsible_user", provider: input.binding.provider, method }))]; + for (const binding of bindings) { + variants.push({ agentRuntimeConfig: { ...record(input.agentRuntimeConfig), aiConnection: binding }, agentConfigRevision: input.agentConfigRevision }); + } + let expectedRuntimeConfig = record(input.agentRuntimeConfig); + for (let index = 0; index < Math.min(20, input.revisions.length); index++) { + const revision = input.revisions[index]!; + if (index === 0 && revision.id !== record(input.agentConfigRevision).id) break; + if (!isDeepStrictEqual(record(revision.afterConfig.runtimeConfig), expectedRuntimeConfig)) break; + if (!aiConnectionOnlyConfigRevision(revision)) break; + // An incomplete bounded history is not sufficient to waive a revision. + const previous = input.revisions[index + 1]; + if (!previous && input.revisions.length >= 21) break; + variants.push({ agentRuntimeConfig: record(revision.beforeConfig.runtimeConfig), agentConfigRevision: aiConnectionRevisionMetadata(previous) }); + expectedRuntimeConfig = record(revision.beforeConfig.runtimeConfig); + } + } + const overrides = input.router && !isDeepStrictEqual(input.issueOverrides, input.originalIssueOverrides) + ? [input.issueOverrides, input.originalIssueOverrides] : [input.issueOverrides]; + return variants.flatMap(variant => configs.flatMap(effectiveAdapterConfig => overrides.map(issueOverrides => ({ ...variant, effectiveAdapterConfig, issueOverrides })))); +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 63dd48607ee..70f66d0934c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -36,6 +36,7 @@ import { connectionIntentService } from "./connection-intents.js"; import { managedAiSessionFingerprintConfig, prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, isAiConnectionBusy, AI_AUTH_ENV_KEYS } from "./ai-connection-runtime.js"; import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type AiConnectionRouterSelection } from "@paperclipai/shared"; import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnectionRouterTaskSettings } from "./ai-connection-router.js"; +import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "./ai-connection-session.js"; import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js"; import { CONVERSATION_CONTINUATION_POLICY, claimedAdapterType, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.js"; import { recordExecutionWait } from "./execution-wait.js"; @@ -6958,6 +6959,7 @@ export function resolveTaskSessionConfigFreshness(input: { configuredModel: string | null; taskSessionParams: Record | null | undefined; configMetadata: EffectiveRunSessionConfigMetadata | null; + compatibleConfigMetadata?: readonly EffectiveRunSessionConfigMetadata[]; wakeResetReason?: string | null; preserveLegacySessionWithoutConfigMetadata?: boolean; }): TaskSessionConfigFreshnessDecision { @@ -7005,7 +7007,8 @@ export function resolveTaskSessionConfigFreshness(input: { ); } else if ( storedConfig && - storedConfig.fingerprint !== input.configMetadata.fingerprint + storedConfig.fingerprint !== input.configMetadata.fingerprint && + !input.compatibleConfigMetadata?.some(candidate => candidate.version === storedConfig.version && candidate.fingerprint === storedConfig.fingerprint) ) { changedCategories = changedEffectiveRunSessionConfigCategories({ previous: storedConfig.categoryFingerprints, @@ -20905,6 +20908,7 @@ export function heartbeatService( const persistedRouterPoolId = routerHasPersistedInput ? readNonEmptyString(parseObject(context.aiRouterSelection).poolId) : null; const requestedAiBinding = persistedRouterPoolId ? { mode: "router" as const, connectionId: persistedRouterPoolId } : agent.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(agent.runtimeConfig.aiConnection) : undefined; let aiBinding = requestedAiBinding?.mode === "router" ? undefined : requestedAiBinding; + const originalAiIssueOverrides = issueAssigneeOverrides; if (requestedAiBinding?.mode === "router") { try { context.aiRouterTaskKey = readNonEmptyString(context.aiRouterTaskKey) ?? taskKey ?? run.id; @@ -21700,9 +21704,11 @@ export function heartbeatService( }); } const savedAiAccount = parseObject(run.contextSnapshot?.aiConnection); - if (persistedNativeExecutionInput && (savedAiAccount.sessionIdentity - ? savedAiAccount.sessionIdentity !== managedAiRuntime.sessionIdentity - : savedAiAccount.identity !== managedAiRuntime.identity)) { + if (persistedNativeExecutionInput && !managedAiSessionIdentityCompatible( + savedAiAccount.sessionIdentity ?? savedAiAccount.identity, + managedAiRuntime.sessionIdentity, + managedAiRuntime.identity, + )) { throw new ConfigurationIncompleteFailure("The AI account changed while this native run was suspended. Start a new execution.", { configurationIncomplete: { reason: "ai_connection_changed", actionUrl: `/agents/${agent.id}/runtime` } }); } Object.assign(resolvedConfig, managedAiRuntime.config); @@ -21774,8 +21780,7 @@ export function heartbeatService( agent.companyId, agent.id, ); - const sessionConfigMetadata = - await buildEffectiveRunSessionConfigMetadata({ + const sessionConfigMetadataInput = { adapterType: agent.adapterType, effectiveAdapterConfig: runtimeConfig, managedAiHome: managedAiRuntime?.home, @@ -21839,7 +21844,24 @@ export function heartbeatService( latestAgentConfigRevision.createdAt.toISOString(), } : null, + }; + const sessionConfigMetadata = await buildEffectiveRunSessionConfigMetadata(sessionConfigMetadataInput); + let compatibleConfigMetadata: EffectiveRunSessionConfigMetadata[] = []; + if (managedAiRuntime && aiBinding && taskSession && + readConfigFingerprintFromSessionParams(taskSession.sessionParamsJson)?.fingerprint !== sessionConfigMetadata.fingerprint) { + const revisions = requestedAiBinding?.mode === "router" + ? await db.select().from(agentConfigRevisions).where(and(eq(agentConfigRevisions.companyId, agent.companyId), eq(agentConfigRevisions.agentId, agent.id))).orderBy(desc(agentConfigRevisions.createdAt), desc(agentConfigRevisions.id)).limit(21) + : []; + const candidates = aiConnectionSessionCompatibilityInputs({ + effectiveAdapterConfig: runtimeConfig, agentRuntimeConfig: agent.runtimeConfig, + agentConfigRevision: sessionConfigMetadataInput.agentConfigRevision, + issueOverrides: issueAssigneeOverrides, originalIssueOverrides: originalAiIssueOverrides, + binding: aiBinding, router: requestedAiBinding?.mode === "router", + storedIdentity: taskSession.sessionParamsJson?.paperclipAiCredentialIdentity, + sessionIdentity: managedAiRuntime.sessionIdentity, credentialIdentity: managedAiRuntime.identity, revisions, }); + compatibleConfigMetadata = await Promise.all(candidates.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...sessionConfigMetadataInput, ...candidate }))); + } const configuredModel = readConfiguredModelFromAdapterConfig(runtimeConfig); const wakeSessionResetReason = describeSessionResetReason(context); @@ -21849,6 +21871,7 @@ export function heartbeatService( taskSessionParams: taskSession?.sessionParamsJson ?? taskSessionDecodedParams, configMetadata: sessionConfigMetadata, + compatibleConfigMetadata, wakeResetReason: wakeSessionResetReason, preserveLegacySessionWithoutConfigMetadata: acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision, @@ -23088,10 +23111,11 @@ export function heartbeatService( delete context.paperclipPreviousSessionId; } - const taskSessionCredentialCompatible = isTaskSessionCredentialCompatible( - taskSession?.sessionParamsJson, - managedAiRuntime?.sessionIdentity, - ) || Boolean(managedAiRuntime && isTaskSessionCredentialCompatible(taskSession?.sessionParamsJson, managedAiRuntime.identity)); + const taskSessionCredentialCompatible = !managedAiRuntime || managedAiSessionIdentityCompatible( + taskSession?.sessionParamsJson?.paperclipAiCredentialIdentity, + managedAiRuntime.sessionIdentity, + managedAiRuntime.identity, + ); if (managedAiRuntime) { sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.sessionIdentity; if (!taskSessionCredentialCompatible) {