fix: preserve managed Codex sessions through credential refresh

Partition recognized Codex subscription credentials by account and principal, retain exact-current-credential compatibility for the previous identity format, and keep model and authorization changes significant. Include the transaction-pooling lease correction from e704e1c9ae (#13347).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 19:39:30 -05:00
parent b450ece5f2
commit b802e34ffb
4 changed files with 253 additions and 17 deletions

View File

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

View File

@ -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<string, unknown>) => `${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<string, any>) => 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<string, any>) => { 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<string, any>) => { 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<string, any>) => { 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<string, any>) => { 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<string, unknown>) => `${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<ReturnType<typeof prepareManagedAiRuntime>>, config: Record<string, unknown> = 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<ReturnType<typeof prepareManagedAiRuntime>>) => (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);

View File

@ -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<string, unknown> | null =>
value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : null;
const text = (value: unknown): value is string =>
typeof value === "string" && value.length > 0 && value.trim() === value;
const claims = (token: unknown): Record<string, unknown> | 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<string, unknown> | 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) {

View File

@ -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<EffectiveRunSessionConfigMetadata> {
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,
);