diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 3cefc7aaad..bcac24decd 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -103,14 +103,23 @@ grant's credentials. Inherited credential variables are cleared. Conflicting project authentication and provider-routing overrides are rejected. Managed failure cannot reactivate host or legacy credentials. -Subscription invocations take a grant-scoped database advisory lease. Two +Subscription invocations take a grant-scoped, transaction-held database advisory +lease so the lock remains on one backend through transaction-pooling proxies. Two different users' grants can run concurrently; a second invocation of the same subscription receives a retryable busy response while it is in use. Refreshes are merged only into the originating active grant, with reconnect/revocation version checks. Temporary homes are removed on normal completion or failure. Session reuse includes grant identity, responsible user, and credential -generation. A changed identity starts a fresh provider session. Managed native +generation. For recognized Codex subscription credentials, generation describes +the account and principal rather than rotating tokens or token timestamps. +Same-account token refresh and generated authentication-home paths therefore +preserve the session; changes to the principal, account, permissions, model, or +user configuration still invalidate it. Opaque credentials and API keys retain +credential-byte change detection. The prior raw-hash identity is accepted only +when it matches the exact currently authorized credential, allowing that identity +format to upgrade without treating an older or different credential as equivalent. +A changed identity starts a fresh provider session. Managed native executions use per-turn lifecycle cleanup; a suspended native execution whose credential identity changed must restart as a new execution. diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 61742ab664..ed3f226981 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -3,7 +3,7 @@ import { connectionIntentDeliveryService } from "../services/connection-intent-d import { issueRecoveryActionService } from "../services/issue-recovery-actions.js"; import * as localCredentials from "../services/local-ai-credentials.js"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { randomUUID } from "node:crypto"; +import { randomUUID, createHash } from "node:crypto"; import { mkdtemp, rm, access, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -12,8 +12,8 @@ import { createDb, companies, agents, heartbeatRuns, companyMemberships, connect import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres"; import { aiConnectionService } from "../services/ai-connections.js"; import * as executionTarget from "@paperclipai/adapter-utils/execution-target"; -import { prepareManagedAiRuntime, assertManagedAiProjectAuth, managedAiSessionFingerprintConfig } from "../services/ai-connection-runtime.js"; -import { buildEffectiveRunSessionConfigMetadata } from "../services/heartbeat.js"; +import { prepareManagedAiRuntime, assertManagedAiProjectAuth, managedAiSessionFingerprintConfig, managedAiCredentialGeneration, managedAiCredentialIdentityMatches } from "../services/ai-connection-runtime.js"; +import { buildEffectiveRunSessionConfigMetadata, resolveTaskSessionConfigFreshness } from "../services/heartbeat.js"; import { toolAccessService } from "../services/tool-access.js"; import { secretService } from "../services/secrets.js"; import { connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared"; @@ -225,6 +225,21 @@ describe("managed AI connections", () => { it("serializes subscription refresh and releases the lease after execution", async () => { const subscription = { ...input, binding: { ...binding, method: "subscription" as const }, responsibleUserId: "alice", config: { model: "same-model" } }; const first = await prepareManagedAiRuntime(db, subscription); + const selected = await service.select({ ...subscription, userId: "alice" }); + const lockKey = `ai-runtime:${selected.grant.id}`; + const held = await db.execute(sql` + select activity.state, activity.xact_start + from pg_locks locks join pg_stat_activity activity on activity.pid = locks.pid + where locks.locktype = 'advisory' and locks.granted + and locks.classid = (hashtextextended(${lockKey}, 0) >> 32)::int::oid + and locks.objid = (hashtextextended(${lockKey}, 0) & 4294967295)::oid + and locks.objsubid = 1 + `); + // A transaction-pooling proxy may move an idle, unpinned client to a + // different backend. The lock must hold a transaction for its lifetime. + expect(held).toHaveLength(1); + expect(held[0].state).toBe("idle in transaction"); + expect(held[0].xact_start).not.toBeNull(); await expect(prepareManagedAiRuntime(db, subscription)).rejects.toThrow("in use"); await first.cleanup(); const next = await prepareManagedAiRuntime(db, subscription); @@ -248,6 +263,115 @@ describe("managed AI connections", () => { await second.cleanup(); expect(await service.credential(await service.select({ ...runInput, userId: "alice" }))).toBe(auth("reconnect", 12)); }); + it("partitions subscription sessions by principal and account while retaining unknown credential changes", () => { + const jwt = (claims: Record) => `${Buffer.from(JSON.stringify({ alg: "RS256" })).toString("base64url")}.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.fixture-signature`; + const credential = (marker: string, account = "account", subject = "subject", user = "user", scope = "openid profile") => ({ + auth_mode: "chatgpt", tokens: { account_id: account, refresh_token: `refresh-${marker}`, + id_token: jwt({ iss: "https://auth.openai.com", sub: subject, aud: "codex-client", exp: marker, "https://api.openai.com/auth": { chatgpt_account_id: account, chatgpt_user_id: user } }), + access_token: jwt({ iss: "https://auth.openai.com", sub: subject, aud: "api", scope, jti: marker, "https://api.openai.com/auth": { chatgpt_account_id: account, chatgpt_user_id: user } }), + }, last_refresh: marker, + }); + const generation = (value: unknown) => managedAiCredentialGeneration("openai", "subscription", JSON.stringify(value)); + const first = credential("first"), next = credential("next"); + expect(generation(first)).toBe(generation(next)); + const reorder = (value: unknown): unknown => Array.isArray(value) ? value.map(reorder) : value && typeof value === "object" + ? Object.fromEntries(Object.entries(value).reverse().map(([key, entry]) => [key, reorder(entry)])) : value; + expect(generation({ ...first, config: { first: 1, nested: { left: true, right: false } } })).toBe(generation(reorder({ ...next, config: { first: 1, nested: { left: true, right: false } } }))); + const withClaims = (value: typeof first, mutate: (claims: Record) => void) => { + const updated = structuredClone(value); + for (const key of ["id_token", "access_token"] as const) { + const claims = JSON.parse(Buffer.from(updated.tokens[key].split(".")[1], "base64url").toString("utf8")); + mutate(claims);updated.tokens[key] = jwt(claims); + } + return updated; + }; + const alias = (claims: Record) => { const auth = claims["https://api.openai.com/auth"];auth.user_id = auth.chatgpt_user_id;delete auth.chatgpt_user_id; }; + expect(generation(withClaims(first, alias))).toBe(generation(withClaims(next, alias))); + expect(generation(withClaims(first, alias))).toBe(generation(first)); + const membership = (id: string) => (claims: Record) => { claims["https://api.openai.com/auth"].chatgpt_account_user_id = id; }; + expect(generation(withClaims(first, membership("member-first")))).not.toBe(generation(withClaims(next, membership("member-next")))); + const ambiguous = (claims: Record) => { claims["https://api.openai.com/auth"].user_id = "conflicting-user"; }; + expect(generation(withClaims(first, ambiguous))).not.toBe(generation(withClaims(next, ambiguous))); + const scopes = (scp: unknown) => (claims: Record) => { delete claims.scope;claims.scp = scp; }; + expect(generation(withClaims(first, scopes(["openid", "profile"])))).toBe(generation(withClaims(next, scopes(["profile", "openid"])))); + expect(generation(withClaims(first, scopes(["openid"])))).not.toBe(generation(withClaims(next, scopes(["openid", "new-permission"])))); + expect(generation(withClaims(first, scopes({ malformed: true })))).not.toBe(generation(withClaims(next, scopes({ malformed: true })))); + for (const changed of [credential("next", "other-account"), credential("next", "account", "other-subject"), credential("next", "account", "subject", "other-user"), credential("next", "account", "subject", "user", "openid new-scope"), { ...next, unknownSetting: "changed" }, { ...next, tokens: { ...next.tokens, account_id: "mismatched-account" } }, { ...next, tokens: { ...next.tokens, access_token: credential("next", "account", "different-subject").tokens.access_token } }]) { + expect(generation(changed)).not.toBe(generation(first)); + } + // A shared account alone cannot establish a principal. Unknown tokens keep byte-level identity. + for (const mutate of [ + (value: typeof first) => ({ ...value, tokens: { ...value.tokens, id_token: "opaque" } }), + (value: typeof first) => ({ ...value, tokens: { ...value.tokens, access_token: "opaque" } }), + (value: typeof first) => ({ ...value, tokens: { ...value.tokens, id_token: jwt({ iss: "https://other.invalid", sub: "subject" }) } }), + (value: typeof first) => ({ ...value, tokens: { ...value.tokens, id_token: jwt({ iss: "https://auth.openai.com", aud: "client" }) } }), + (value: typeof first) => ({ ...value, OPENAI_API_KEY: "actual-api-key" }), + ]) expect(generation(mutate(first))).not.toBe(generation(mutate(next))); + for (const provider of ["openai", "anthropic", "openrouter", "xai"] as const) { + expect(managedAiCredentialGeneration(provider, "api_key", "first-key")).not.toBe(managedAiCredentialGeneration(provider, "api_key", "next-key")); + } + for (const provider of ["anthropic", "xai"] as const) { + expect(managedAiCredentialGeneration(provider, "subscription", "opaque-first")).not.toBe(managedAiCredentialGeneration(provider, "subscription", "opaque-next")); + } + }); + it("keeps Claude opaque setup-token sessions stable only while the same token remains selected", async () => { + const intent = { provider: "anthropic", method: "subscription", name: "Opaque Claude session", ownership: "personal", agentIds: [], allAgents: true } as const; + const saved = await service.save(companyId, "alice", { ...intent, agentIds: [] }, "opaque-claude-first"); + const runInput = { companyId, agentId, adapterType: "claude_local", responsibleUserId: "alice", binding: { provider: "anthropic", method: "subscription", mode: "delegated", ...saved } as const, config: { model: "same-model" } }; + const first = await prepareManagedAiRuntime(db, runInput);await first.cleanup(); + const unchanged = await prepareManagedAiRuntime(db, runInput);try { expect(unchanged.identity).toBe(first.identity); } finally { await unchanged.cleanup(); } + await service.save(companyId, "alice", { ...intent, agentIds: [], connectionId: saved.connectionId }, "opaque-claude-next"); + const changed = await prepareManagedAiRuntime(db, runInput);try { expect(changed.identity).not.toBe(first.identity);expect(changed.config.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("opaque-claude-next"); } finally { await changed.cleanup(); } + }); + it("preserves managed Codex session fingerprints after same-account credential refresh", async () => { + const jwt = (claims: Record) => `${Buffer.from(JSON.stringify({ alg: "RS256" })).toString("base64url")}.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.fixture-signature`; + const auth = (marker: string, hour: number) => JSON.stringify({ auth_mode: "chatgpt", tokens: { + account_id: "refresh-session-account", + id_token: jwt({ iss: "https://auth.openai.com", sub: "refresh-session-user", aud: ["codex-fixture-client"], exp: hour, "https://api.openai.com/auth": { chatgpt_account_id: "refresh-session-account", chatgpt_user_id: "refresh-session-user" } }), + access_token: jwt({ iss: "https://auth.openai.com", sub: "refresh-session-user", aud: ["https://api.openai.com/v1", "codex-service"], exp: hour, jti: marker, scp: ["openid", "profile"], "https://api.openai.com/auth": { chatgpt_account_id: "refresh-session-account", chatgpt_user_id: "refresh-session-user" } }), refresh_token: `refresh-${marker}`, + }, last_refresh: `2026-09-10T${hour}:00:00Z` }); + const saved = await service.save(companyId, "alice", { provider: "openai", method: "subscription", name: "Stable refresh session", ownership: "personal", agentIds: [], allAgents: true }, auth("first", 10)); + const selectedBinding = { provider: "openai", method: "subscription", mode: "delegated", ...saved } as const; + const runInput = { companyId, agentId, adapterType: "codex_local", responsibleUserId: "alice", binding: selectedBinding, config: { model: "same-model" } }; + const metadata = async (runtime: Awaited>, config: Record = runtime.config, legacy = true) => buildEffectiveRunSessionConfigMetadata({ + adapterType: runInput.adapterType, effectiveAdapterConfig: managedAiSessionFingerprintConfig(config, runtime), + managedAiLegacyCredentialIdentity: legacy ? runtime.legacyCredentialIdentity : undefined, + agentRuntimeConfig: { aiConnection: selectedBinding }, issueOverrides: null, workspaceConfig: null, + environment: null, environmentEnv: null, projectEnv: null, routineEnv: null, runtimeSkills: [], + }); + const fingerprint = async (runtime: Awaited>) => (await metadata(runtime)).fingerprint; + const first = await prepareManagedAiRuntime(db, runInput); + let firstFingerprint: string; + try { + firstFingerprint = await fingerprint(first); + const oldIdentity = `${saved.grantId}:alice:${createHash("sha256").update(auth("first", 10)).digest("hex").slice(0, 16)}`; + expect(first.legacyCredentialIdentity).toBe(oldIdentity); + expect(managedAiCredentialIdentityMatches(oldIdentity, first)).toBe(true); + expect(managedAiCredentialIdentityMatches(undefined, first)).toBe(false); + expect(managedAiCredentialIdentityMatches(oldIdentity.replace(":alice:", ":bob:"), first)).toBe(false); + const original = await metadata(first, { ...first.config, managedAiConnection: { ...first.config.managedAiConnection, identity: oldIdentity } }, false); + const current = await metadata(first); + const session = { __paperclipConfiguredModel: "same-model", __paperclipConfigFingerprint: original.fingerprint, + __paperclipConfigFingerprintVersion: original.version, __paperclipConfigCategories: original.categories, + __paperclipConfigCategoryFingerprints: original.categoryFingerprints }; + expect(current.fingerprint).not.toBe(original.fingerprint); + expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: "same-model", taskSessionParams: session, configMetadata: current }).reset).toBe(false); + for (const changed of [{ ...first.config, model: "different-model" }, { ...first.config, env: { ...first.config.env, CUSTOM_FLAG: "changed" } }]) { + expect(resolveTaskSessionConfigFreshness({ hasTaskSession: true, configuredModel: "same-model", taskSessionParams: session, configMetadata: await metadata(first, changed) }).reset).toBe(true); + } + await writeFile(path.join(String(first.config.env.CODEX_HOME), "auth.json"), auth("refreshed", 11)); + } finally { await first.cleanup(); } + expect(await service.credential(await service.select({ ...runInput, userId: "alice" }))).toBe(auth("refreshed", 11)); + const next = await prepareManagedAiRuntime(db, runInput); + try { + expect(next.identity).toBe(first.identity); + expect(await fingerprint(next)).toBe(firstFingerprint!); + expect(managedAiCredentialIdentityMatches(first.identity, next)).toBe(true); + // Exact old-byte compatibility cannot exempt another credential generation. + expect(managedAiCredentialIdentityMatches(first.legacyCredentialIdentity, next)).toBe(false); + + } finally { await next.cleanup(); } + }); it("enforces the shared transport discriminator and existing harness compatibility", () => { expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "ai", transport: "mcp_remote" }).success).toBe(false); expect(connectionPurposeTransportSchema.safeParse({ connectionPurpose: "tool", transport: "runtime_auth" }).success).toBe(false); diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index beed2ff971..36262ee1de 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { canonicalJson } from "@paperclipai/shared/portability-hash"; import { unprocessable } from "../errors.js"; import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; import os from "node:os"; @@ -182,15 +183,24 @@ done`, async function acquireCredentialLease(db: Db, grantId: string) { const client = await db.$client.reserve(); try { + // A reserved client pins our connection to PgBouncer, not its backend. + // Keep the lease in one transaction so transaction-pooling deployments + // cannot acquire and release it on different PostgreSQL sessions. + await client`begin`; + await client`set local idle_in_transaction_session_timeout = 0`; const [result] = - await client`select pg_try_advisory_lock(hashtextextended(${`ai-runtime:${grantId}`}, 0)) as acquired`; + await client`select pg_try_advisory_xact_lock(hashtextextended(${`ai-runtime:${grantId}`}, 0)) as acquired`; if (!result.acquired) throw unprocessable( "This subscription is in use. Retry when its current execution finishes.", { code: "ai_connection_busy" }, ); } catch (error) { - client.release(); + try { + await client`rollback`; + } finally { + client.release(); + } throw error; } let released = false; @@ -198,13 +208,92 @@ async function acquireCredentialLease(db: Db, grantId: string) { if (released) return; released = true; try { - await client`select pg_advisory_unlock(hashtextextended(${`ai-runtime:${grantId}`}, 0))`; + await client`rollback`; } finally { client.release(); } }; } +/** A session partition, never a substitute for selecting and authorizing the grant. */ +export function managedAiCredentialGeneration( + provider: AiConnectionBinding["provider"], + method: AiConnectionBinding["method"], + value: string, +): string { + let identityMaterial = value; + if (provider === "openai" && method === "subscription") { + try { + const record = (value: unknown): Record | null => + value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : null; + const text = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.trim() === value; + const claims = (token: unknown): Record | null => { + if (!text(token)) return null; + const parts = token.split("."); + if (parts.length !== 3 || parts.some(part => !/^[A-Za-z0-9_-]+$/.test(part))) return null; + const header = record(JSON.parse(Buffer.from(parts[0], "base64url").toString("utf8"))); + if (header?.alg !== "RS256") return null; + return record(JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"))); + }; + const audience = (value: unknown): string[] | null => { + const values = typeof value === "string" ? [value] : value; + return Array.isArray(values) && values.length > 0 && values.every(text) + ? [...new Set(values)].sort() : null; + }; + const auth = record(JSON.parse(value)), tokens = record(auth?.tokens); + const id = claims(tokens?.id_token), access = claims(tokens?.access_token); + const idAuth = record(id?.["https://api.openai.com/auth"]); + const accessAuth = record(access?.["https://api.openai.com/auth"]); + const principal = (auth: Record | null): string | null => { + if (!auth) return null; + const user = auth.chatgpt_user_id ?? auth.user_id; + if (!text(user) || (auth.chatgpt_user_id !== undefined && auth.user_id !== undefined && auth.chatgpt_user_id !== auth.user_id)) return null; + return user; + }; + const idUser = principal(idAuth), accessUser = principal(accessAuth); + const idAudience = audience(id?.aud), accessAudience = audience(access?.aud); + if (auth && tokens && id && access && idAuth && accessAuth && + (auth.auth_mode === undefined || auth.auth_mode === "chatgpt") && + !auth.OPENAI_API_KEY && text(tokens.account_id) && text(tokens.refresh_token) && + id.iss === "https://auth.openai.com" && access.iss === id.iss && + text(id.sub) && access.sub === id.sub && idAudience && accessAudience && + idAuth.chatgpt_account_id === tokens.account_id && accessAuth.chatgpt_account_id === tokens.account_id && + idUser !== null && accessUser === idUser && + (idAuth.chatgpt_account_user_id === undefined || text(idAuth.chatgpt_account_user_id)) && + (accessAuth.chatgpt_account_user_id === undefined || text(accessAuth.chatgpt_account_user_id)) && + (idAuth.chatgpt_account_user_id === undefined || accessAuth.chatgpt_account_user_id === undefined || idAuth.chatgpt_account_user_id === accessAuth.chatgpt_account_user_id) && + (access.scope === undefined || typeof access.scope === "string") && + (access.scp === undefined || (Array.isArray(access.scp) && access.scp.every(text)))) { + // Rotating tokens and token timestamps do not change the provider principal. + // Unknown root/token configuration still partitions sessions conservatively. + const { tokens: _tokens, last_refresh: _refresh, ...authConfig } = auth; + const { id_token: _id, access_token: _access, refresh_token: _token, ...tokenConfig } = tokens; + identityMaterial = canonicalJson({ + kind: "codex-account-principal-v1", authConfig, tokenConfig, + issuer: id.iss, subject: id.sub, accountId: tokens.account_id, + userId: idUser, idAudience, accessAudience, + accountUserId: accessAuth.chatgpt_account_user_id ?? idAuth.chatgpt_account_user_id ?? null, + scope: typeof access.scope === "string" ? [...new Set(access.scope.split(/\s+/).filter(Boolean))].sort() : null, + scp: Array.isArray(access.scp) ? [...new Set(access.scp)].sort() : null, + }); + } + } catch { + // Opaque, malformed or unprovable identity retains credential-change invalidation. + } + } + return createHash("sha256").update(identityMaterial).digest("hex").slice(0, 16); +} + +export function managedAiCredentialIdentityMatches( + stored: unknown, + runtime: { identity: string; legacyCredentialIdentity?: string }, +): boolean { + return typeof stored === "string" && + (stored === runtime.identity || stored === runtime.legacyCredentialIdentity); +} + export async function prepareManagedAiRuntime( db: Db, input: { @@ -315,11 +404,12 @@ export async function prepareManagedAiRuntime( }); env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; } - const generation = createHash("sha256") - .update(value) - .digest("hex") - .slice(0, 16); - const identity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${generation}`; + const generation = managedAiCredentialGeneration(input.binding.provider, input.binding.method, value); + const identityPrefix = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:`; + const identity = `${identityPrefix}${generation}`; + // Recognize only the old algorithm for these exact current credential bytes. + // An older credential, account, grant or responsible user gets no exemption. + const legacyIdentity = `${identityPrefix}${createHash("sha256").update(value).digest("hex").slice(0, 16)}`; return { config: { ...input.config, @@ -330,6 +420,7 @@ export async function prepareManagedAiRuntime( accountName: selection.connection.name, accountOwnerUserId: selection.grant.subjectUserId, identity, + legacyCredentialIdentity: legacyIdentity === identity ? undefined : legacyIdentity, cleanup: async () => { try { if (subscriptionFile) { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a073531d9b..5bc978c4e4 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -9,7 +9,7 @@ import { hasRemoteTerminationReceipt, remoteExecutionHasStopped, remoteTerminati import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js"; import { admitExplicitNativeContinuation, undeliveredLegacyUserCommentIds } from "./explicit-native-continuation.js"; import { connectionIntentService } from "./connection-intents.js"; -import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, AI_AUTH_ENV_KEYS, managedAiSessionFingerprintConfig } from "./ai-connection-runtime.js"; +import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings, AI_AUTH_ENV_KEYS, managedAiSessionFingerprintConfig, managedAiCredentialIdentityMatches } from "./ai-connection-runtime.js"; import { aiConnectionBindingSchema } from "@paperclipai/shared"; import { executionBlockerPredicate, getExecutionBlocker } from "./execution-blocker.js"; import { CONVERSATION_CONTINUATION_POLICY, claimedAdapterType, runUsedConversationAdapter, hasConversationContinuationPolicy, isConversationAdapter } from "./conversation-continuation.js"; @@ -6403,6 +6403,8 @@ export async function buildEffectiveRunSessionConfigMetadata(input: { secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; runtimeSkills: unknown; agentConfigRevision?: unknown; + /** Host-derived old identity for the exact current managed credential only. */ + managedAiLegacyCredentialIdentity?: string; }): Promise { const secretManifest = input.secretManifest ?? []; const instructions = await resolveInstructionsConfigFingerprintMetadata( @@ -6463,6 +6465,15 @@ export async function buildEffectiveRunSessionConfigMetadata(input: { paperclipConnectorSkillDigest: null, }); } + if (input.managedAiLegacyCredentialIdentity) { + for (const adapterConfig of [...adapterVariants]) { + const managed = parseObject(adapterConfig.managedAiConnection); + if (typeof managed.identity !== "string") continue; + adapterVariants.push({ ...adapterConfig, managedAiConnection: { + ...managed, identity: input.managedAiLegacyCredentialIdentity, + } }); + } + } const compatibleFingerprints = [...new Set( [categoryValues.workspaceConfig, ...legacyWorkspaceVariants].flatMap((workspaceConfig) => adapterVariants.map((adapterConfig) => createEffectiveRunConfigFingerprints({ @@ -20856,7 +20867,7 @@ export function heartbeatService( fingerprint: `ai:${agent.id}:${responsibleUserId}:${JSON.stringify(aiBinding)}` }, }); } - if (persistedNativeExecutionInput && parseObject(run.contextSnapshot?.aiConnection).identity !== managedAiRuntime.identity) { + if (persistedNativeExecutionInput && !managedAiCredentialIdentityMatches(parseObject(run.contextSnapshot?.aiConnection).identity, managedAiRuntime)) { 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); @@ -20927,6 +20938,7 @@ export function heartbeatService( await measureSandboxOperation("heartbeat.build_effective_run_session_config_metadata", { operationIndex: 61 }, async () => (buildEffectiveRunSessionConfigMetadata({ adapterType: agent.adapterType, effectiveAdapterConfig: managedAiSessionFingerprintConfig(runtimeConfig, managedAiRuntime), + managedAiLegacyCredentialIdentity: managedAiRuntime?.legacyCredentialIdentity, agentRuntimeConfig: agent.runtimeConfig, issueOverrides: issueAssigneeOverrides, workspaceConfig: { @@ -22227,7 +22239,7 @@ export function heartbeatService( if (managedAiRuntime) { sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.identity; - if (taskSessionDecodedParams?.paperclipAiCredentialIdentity !== managedAiRuntime.identity) { + if (!managedAiCredentialIdentityMatches(taskSessionDecodedParams?.paperclipAiCredentialIdentity, managedAiRuntime)) { runtimeSessionIdForAdapter = null; runtimeSessionParamsForAdapter = null; previousSessionDisplayId = null; @@ -22742,7 +22754,7 @@ export function heartbeatService( })(), }))); const taskSessionIdentityChanged = Boolean(sandboxWorkFolders?.identityChanged - || (managedAiRuntime && taskSessionDecodedParams?.paperclipAiCredentialIdentity !== managedAiRuntime.identity)); + || (managedAiRuntime && !managedAiCredentialIdentityMatches(taskSessionDecodedParams?.paperclipAiCredentialIdentity, managedAiRuntime))); const taskNativeSessionId = taskSessionIdentityChanged ? null : readNonEmptyString( taskSessionDecodedParams?.sessionId, );