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:
parent
b450ece5f2
commit
b802e34ffb
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue