fix(connections): recover missing personal AI credentials in chat (#15376)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-06 15:36:01 -05:00
1 parent ea8e686197
commit 2ca0d26a99
19 files changed
+359 -35

No files matched your search

+7
View File
@@ -1753,6 +1753,13 @@ Legacy agents retain their authentication until validated adoption. See
[AI Connections](connections/AI-CONNECTIONS.md) for company isolation, compatible
methods, lifecycle, runtime enforcement, and migration details.
Missing personal AI credentials detected before adapter dispatch also produce
the inline connection card. Every missing binding must belong to the same
compatible AI provider. The responsible user connects their own account and
explicitly adopts Connections; another user's onboarding key is never reused.
Acceptance resumes only the matching configuration-blocked task through durable
continuation delivery. Unrelated configuration gaps retain operator recovery.
The selected AI connection supports an on-demand usage probe through the common
connection service, independent of legacy/native execution. The board usage
endpoint rechecks company membership and the credential's human audience before
+2
View File
@@ -37,6 +37,8 @@ outcome. See [the implementation contract](SPEC-implementation.md#124-connection
Model authentication failures also surface a provider-specific Connections card
on the task immediately after failure. Users can reconnect inline and resume;
legacy agents keep their authentication until an explicit, validated adoption.
Missing personal AI credentials found before a run starts use the same card.
The responsible user connects their own account inside the task and continues.
See [AI Connections](connections/AI-CONNECTIONS.md).
AI connections also expose an on-demand, credential-scoped usage probe. It
+11
View File
@@ -237,6 +237,17 @@ repairs the connection. Unsupported providers and failures that could not create
a card retain their existing recovery path. Tool permission errors and provider quota failures do not
request model authentication.
Pre-dispatch `configuration_incomplete` failures also show this card when every
missing binding is a personal `user_secret_ref` for the same compatible AI
provider. This includes a teammate who has no value for an onboarding
`ANTHROPIC_API_KEY` definition. The card asks that person to connect their own
account. It does not use another teammate's secret. Mixed gaps, company secrets,
and unrelated tool credentials keep the existing operator recovery path.
After explicit, validated adoption, the durable delivery reopens only the
blocked task whose latest failure and active configuration recovery still match
the card. A newer failure, reassignment, manual hold, or restricted external chat
does not resume through an old card.
An attributed managed credential is marked as needing reauthorization only if
its stored generation still matches the failed run. Late failures cannot
invalidate a refreshed or reconnected credential. Repair preserves the selected
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { aiBindingForAuthRecovery, isAiAuthenticationBlocked, isAiAuthenticationRepairable } from "../services/ai-auth-failure.js";
import { classifyRunLiveness } from "../services/run-liveness.js";
const missingClaudeKey = {
bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY",
configPath: "env.ANTHROPIC_API_KEY", errorCode: "user_secret_missing",
consumerType: "agent", responsibleUserId: "new-teammate",
};
const failure = (missingBindings: unknown, reason = "secret_binding_missing") => ({
errorCode: "configuration_incomplete",
resultJson: { configurationIncomplete: { reason, missingBindings } },
});
describe("personal AI credential preflight recovery", () => {
it.each(["user_secret_missing", "secret_inactive"])("offers Claude setup for %s without adopting another provider", errorCode => {
const run = failure([{ ...missingClaudeKey, errorCode }]);
expect(isAiAuthenticationRepairable(run)).toBe(true);
expect(aiBindingForAuthRecovery("claude_local", {}, run)).toEqual({
provider: "anthropic", method: "subscription", mode: "responsible_user",
});
expect(aiBindingForAuthRecovery("codex_local", {}, run)).toBeUndefined();
expect(isAiAuthenticationBlocked({ ...run, livenessState: "failed" })).toBe(false);
expect(isAiAuthenticationBlocked({ ...run, livenessState: "blocked" })).toBe(true);
});
it.each([
["tool credential", [{ ...missingClaudeKey, envKey: "GITHUB_TOKEN", configPath: "env.GITHUB_TOKEN" }]],
["company secret", [{ ...missingClaudeKey, bindingType: "secret_ref" }]],
["unknown error", [{ ...missingClaudeKey, errorCode: "user_secret_definition_missing" }]],
["adapter field", [{ ...missingClaudeKey, configPath: "apiKey" }]],
["additional tool blocker", [missingClaudeKey, { ...missingClaudeKey, envKey: "GITHUB_TOKEN", configPath: "env.GITHUB_TOKEN" }]],
["different providers", [missingClaudeKey, { ...missingClaudeKey, envKey: "OPENAI_API_KEY", configPath: "env.OPENAI_API_KEY" }]],
["empty evidence", []], ["malformed evidence", [null]], ["missing evidence", null],
])("keeps %s on the existing configuration recovery path", (_name, bindings) => {
expect(isAiAuthenticationRepairable(failure(bindings))).toBe(false);
expect(isAiAuthenticationBlocked({ ...failure(bindings), livenessState: "blocked" })).toBe(false);
});
it("only classifies a failed preflight as an inline wait after the card is persisted", () => {
const input = { ...failure([missingClaudeKey]), runStatus: "failed", issue: { status: "blocked" },
stdoutExcerpt: null, stderrExcerpt: null, error: null, continuationAttempt: 0, evidence: null };
expect(classifyRunLiveness(input).livenessState).toBe("failed");
expect(classifyRunLiveness({ ...input, authenticationRepairRequested: true })).toMatchObject({
livenessState: "blocked", nextAction: "Complete the connection request to continue this task",
});
expect(classifyRunLiveness({ ...input, ...failure([]), authenticationRepairRequested: true }).livenessState).toBe("failed");
});
});
+87 -4
View File
@@ -19,6 +19,7 @@ import { prepareManagedAiRuntime, assertManagedAiProjectAuth } from "../services
import { execute as executeGemini, testEnvironment as testGeminiEnvironment } from "@paperclipai/adapter-gemini-local/server";
import { toolAccessService } from "../services/tool-access.js";
import { secretService } from "../services/secrets.js";
import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.js";
import { aiConnectionBindingSchema, connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared";
import express from "express";
import request from "supertest";
@@ -378,6 +379,73 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
await db.insert(heartbeatRuns).values({ companyId, agentId: id, status: "succeeded", responsibleUserId: "alice", contextSnapshot: { issueId }, createdAt: new Date(Date.now() + 1000) });
expect(await intents.requestForRunAuthFailure(runId)).toBeNull();
});
it("repairs a teammate's missing onboarding key with their own inline AI connection", async () => {
const userId = `new-teammate-${randomUUID()}`;
const id = randomUUID();
const issueId = randomUUID();
const runId = randomUUID();
const vault = secretService(db);
const definition = await vault.createUserSecretDefinition(companyId, {
key: `ANTHROPIC_API_KEY.${randomUUID()}`, name: "ANTHROPIC_API_KEY for onboarding",
}, { userId: "alice" });
const ownerSecret = await vault.createCurrentUserSecretValue(companyId, "alice", {
definitionId: definition.id, value: "fixture-original-owner-key",
}, { userId: "alice" });
const adapterConfig = { env: { ANTHROPIC_API_KEY: {
type: "user_secret_ref", key: definition.key, version: "latest", required: true,
} } };
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
const [agent] = await db.insert(agents).values({ id, companyId, name: "Chief of Staff", adapterType: "claude_local", adapterConfig }).returning();
await vault.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: id }, adapterConfig.env);
await db.insert(issues).values({ id: issueId, companyId, title: "Chat with Chief of Staff", status: "blocked", assigneeAgentId: id, responsibleUserId: userId });
const failure = await resolveExecutionRunAdapterConfig({
companyId, agentId: id, adapterType: "claude_local", issueId,
responsibleUserId: userId, executionRunConfig: adapterConfig, projectEnv: null, secretsSvc: vault,
}).catch(error => error);
expect(failure).toMatchObject({ code: "configuration_incomplete", resultJson: {
configurationIncomplete: { reason: "secret_binding_missing", missingBindings: [
expect.objectContaining({ bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY", errorCode: "user_secret_missing", responsibleUserId: userId }),
] },
} });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: id, status: "failed",
errorCode: failure.code, resultJson: failure.resultJson, responsibleUserId: userId, contextSnapshot: { issueId } });
await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId,
kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `missing-key:${issueId}`,
nextAction: "Connect your AI account", ownerType: "board", evidence: { latestRunId: runId } });
const intents = connectionIntentService(db);
const card = await intents.requestForRunAuthFailure(runId);
expect(card).toMatchObject({ service: "anthropic", state: "needs_user_action" });
expect((await intents.setupOptions(card!.interactionId!))).toMatchObject({
aiConnectionRequiresAdoption: true, aiConnection: { provider: "anthropic", mode: "responsible_user" },
interaction: { addresseeUserId: userId, sourceRunId: runId },
});
expect((await intents.requestForRunAuthFailure(runId))?.interactionId).toBe(card!.interactionId);
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined();
const account = await create(userId, "Teammate's Claude");
const setup = await intents.setupOptions(card!.interactionId!);
await intents.complete(card!.interactionId!, account.connectionId, userId, {
validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection! },
});
const wakeup = vi.fn(async (_agentId, opts) => {
await db.insert(agentWakeupRequests).values({ companyId, agentId: id, source: "automation", status: "queued", idempotencyKey: opts.idempotencyKey });
return null;
});
const delivery = connectionIntentDeliveryService(db, { wakeup } as never);
await delivery.deliver(card!.interactionId!);
await delivery.deliver(card!.interactionId!);
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].status).toBe("in_progress");
expect(wakeup).toHaveBeenCalledTimes(1);
expect(wakeup).toHaveBeenCalledWith(id, expect.objectContaining({ contextSnapshot: expect.objectContaining({ forceFreshSession: true }) }));
expect(await issueRecoveryActionService(db).getActiveForIssue(companyId, issueId)).toBeNull();
expect((await db.select().from(companySecrets).where(eq(companySecrets.id, ownerSecret.id)))[0]).toMatchObject({ ownerUserId: "alice", latestVersion: 1, status: "active" });
const runtime = await prepareManagedAiRuntime(db, { companyId, agentId: id, responsibleUserId: userId, adapterType: "claude_local", binding: setup.aiConnection!, config: adapterConfig });
try {
expect(runtime.config.env).toMatchObject({ ANTHROPIC_API_KEY: "fixture-Teammate's Claude" });
expect(runtime.attribution.grantId).toBe(account.grantId);
} finally { await runtime.cleanup(); }
}, 30000);
it.each([
["anthropic", "claude_local", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
["openai", "codex_local", "CODEX_HOME", "OPENAI_API_KEY"],
@@ -1022,6 +1090,13 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
await db.insert(agents).values({ id, companyId, name: "Runner adoption", adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } });
const account = await service.save(companyId, "alice", { provider: "openai", method: "api_key", ownership: "personal", name: "Runner adoption account", apiKey: "fixture-adoption-key", agentIds: inline ? [] : [id], allAgents: false }, "fixture-adoption-key");
await service.setDefault(companyId, "alice", account.grantId);
const tryDeliver = vi.fn(async (interactionId: string) => {
// Dispatch observes committed adoption, never a partially saved choice.
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].status).toBe("accepted");
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toMatchObject({ mode: "responsible_user" });
});
const deliveryModule = await import("../services/connection-intent-delivery.js");
const delivery = vi.spyOn(deliveryModule, "connectionIntentDeliveryService").mockReturnValue({ tryDeliver } as never);
let interactionId: string | undefined;
if (inline) {
const issueId = randomUUID();
@@ -1052,6 +1127,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
resolveTarget.mockResolvedValueOnce(null);
const unavailable = await adopt();
expect(unavailable.status, JSON.stringify(unavailable.body)).toBe(422);
expect(tryDeliver).not.toHaveBeenCalled();
expect(probe).not.toHaveBeenCalled();
expect(providerRequest).not.toHaveBeenCalled();
expect((await db.select().from(agents).where(eq(agents.id, id)))[0].runtimeConfig.aiConnection).toBeUndefined();
@@ -1068,6 +1144,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
try {
const failed = await adopt();
expect(failed.status).toBe(500);
expect(tryDeliver).not.toHaveBeenCalled();
await assertUnchanged();
} finally {
await db.execute(sql.raw("DROP TRIGGER reject_atomic_adoption ON issue_thread_interactions"));
@@ -1083,6 +1160,8 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
expect(saved.body.status).toBe("accepted");
expect(await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.connectionId))).toHaveLength(1);
expect((await adopt()).status).toBe(200);
expect(tryDeliver).toHaveBeenCalledTimes(2);
expect(tryDeliver).toHaveBeenCalledWith(interactionId);
}
expect(acquire).toHaveBeenCalledWith(expect.objectContaining({ companyId, environment: expect.objectContaining({ id: environment.id }) }));
expect(probe).toHaveBeenCalledWith(expect.objectContaining({ executionTarget: target, config: expect.objectContaining({ provider: "codex", model: "gpt-5.6-sol", managedAiConnection: expect.any(Object) }) }));
@@ -1093,7 +1172,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
expect(release).toHaveBeenCalledTimes(inline ? 3 : 2);
expect(JSON.stringify(saved.body)).not.toContain("fixture-adoption-key");
} finally {
providerRequest.mockRestore(); probe.mockRestore(); runtime.mockRestore(); resolveTarget.mockRestore();
delivery.mockRestore(); providerRequest.mockRestore(); probe.mockRestore(); runtime.mockRestore(); resolveTarget.mockRestore();
await settings.update({ defaultEnvironmentId: previous.defaultEnvironmentId });
}
});
@@ -1141,8 +1220,10 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
describe("AI connection recovery delivery", () => {
it.each(["restored", "newer failure", "different blocker", "revoked again", "closed task"])(
"continues only the repaired source failure: %s", async (scenario) => {
it.each(["ai_connection_unavailable", "secret_binding_missing"].flatMap(reason =>
["restored", "newer failure", "different blocker", "revoked again", "closed task"].map(scenario => [reason, scenario]),
))(
"continues only the repaired %s source failure: %s", async (reason, scenario) => {
const userId = `recovery-${randomUUID()}`;
const recoveringAgentId = randomUUID();
const issueId = randomUUID();
@@ -1155,7 +1236,9 @@ describe("AI connection recovery delivery", () => {
const pending = await intents.request({ sub: recoveringAgentId, company_id: companyId, run_id: failedRunId, responsible_user_id: userId }, "anthropic", { purpose: "ai" });
const account = await create(userId, `Recovered ${scenario}`);
expect((await intents.setupOptions(pending.interactionId!)).existingConnections.map(connection => connection.id)).toEqual([account.connectionId]);
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "configuration_incomplete", resultJson: { configurationIncomplete: { reason: "ai_connection_unavailable" } } }).where(eq(heartbeatRuns.id, failedRunId));
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "configuration_incomplete", resultJson: { configurationIncomplete: { reason,
...(reason === "secret_binding_missing" ? { missingBindings: [{ bindingType: "user_secret_ref", envKey: "ANTHROPIC_API_KEY", configPath: "env.ANTHROPIC_API_KEY", errorCode: "user_secret_missing" }] } : {}),
} } }).where(eq(heartbeatRuns.id, failedRunId));
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, issueId));
await issueRecoveryActionService(db).upsertSourceScoped({ companyId, sourceIssueId: issueId, kind: "configuration_validation", cause: "configuration_incomplete", fingerprint: `ai:${issueId}`, nextAction: "Reconnect", ownerType: "board", evidence: { latestRunId: failedRunId } });
await intents.complete(pending.interactionId!, account.connectionId, userId);
+8 -2
View File
@@ -2,6 +2,7 @@ import { aiRoutingHarness } from "@paperclipai/shared";
import { agentIdentityService } from "../services/agent-identity.js";
import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { connectionIntentDeliveryService } from "../services/connection-intent-delivery.js";
import { completeConnectionIntentSchema } from "@paperclipai/shared";
import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js";
import { pipeline } from "node:stream/promises";
@@ -5536,6 +5537,7 @@ export function agentRoutes(
if (!userId || loaded.interaction.addresseeUserId !== userId) throw forbidden("Only the addressed user can adopt this connection");
const connectionId = req.body.connectionId as string;
if (loaded.interaction.status === "accepted" && loaded.interaction.result?.connectionId === connectionId) {
await connectionIntentDeliveryService(db, heartbeat).tryDeliver(interactionId);
res.json(loaded.interaction);
return;
}
@@ -5546,9 +5548,13 @@ export function agentRoutes(
// The binding, install, audit, and card resolution commit together below.
const validatedConnection = await validateManagedAgentBinding(req, agent.companyId, agent.id, agent.adapterType, agent.adapterConfig, setup.aiConnection, agent.defaultEnvironmentId, true, true);
if (validatedConnection?.connectionId !== connectionId) throw conflict("This is no longer the selected account. Reload the task and try again.");
res.json(await intents.complete(interactionId, connectionId, userId, {
const completed = await intents.complete(interactionId, connectionId, userId, {
validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection },
}));
});
// Dispatch the committed continuation now. The persisted delivery remains
// available to the sweeper if this process exits or dispatch is deferred.
await connectionIntentDeliveryService(db, heartbeat).tryDeliver(interactionId);
res.json(completed);
});
router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => {
+45 -3
View File
@@ -1,4 +1,43 @@
import { AI_PROVIDERS, AI_CONNECTION_CAPABILITIES, isAiConnectionCompatible, type AiConnectionBinding } from "@paperclipai/shared";
import { AI_PROVIDERS, AI_CONNECTION_CAPABILITIES, isAiConnectionCompatible, type AiConnectionBinding, type AiProvider } from "@paperclipai/shared";
type AuthenticationFailure = {
errorCode?: string | null;
resultJson?: unknown;
};
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : null;
}
/** A personal model credential gap is repairable without changing other secrets. */
function missingPersonalAiCredentialProvider(run: AuthenticationFailure): AiProvider | undefined {
if (run.errorCode !== "configuration_incomplete") return undefined;
const gap = record(record(run.resultJson)?.configurationIncomplete);
if (gap?.reason !== "secret_binding_missing" || !Array.isArray(gap.missingBindings) || !gap.missingBindings.length) return undefined;
const missingBindings = gap.missingBindings;
return AI_PROVIDERS.find(provider => {
const keys = new Set(Object.values(AI_CONNECTION_CAPABILITIES[provider].methods).map(method => method.envKey));
return missingBindings.every((value: unknown) => {
const binding = record(value);
return binding?.bindingType === "user_secret_ref"
&& ["user_secret_missing", "secret_inactive"].includes(String(binding.errorCode))
&& typeof binding.envKey === "string" && keys.has(binding.envKey)
&& binding.configPath === `env.${binding.envKey}`;
});
});
}
export function isAiConnectionConfigurationFailure(run: AuthenticationFailure): boolean {
return run.errorCode === "configuration_incomplete" && (
record(record(run.resultJson)?.configurationIncomplete)?.reason === "ai_connection_unavailable"
|| Boolean(missingPersonalAiCredentialProvider(run))
);
}
export function isAiAuthenticationRepairable(run: AuthenticationFailure): boolean {
return isAiAuthenticationFailure(run.errorCode) || isAiConnectionConfigurationFailure(run);
}
/** Provider authentication signals only. Tool authorization and quotas need different repairs. */
export function isAiAuthenticationFailure(code: string | null | undefined): boolean {
@@ -9,15 +48,18 @@ export function isAiAuthenticationFailure(code: string | null | undefined): bool
}
/** Only a persisted blocked classification confirms that an inline repair owns recovery. */
export function isAiAuthenticationBlocked(run: { errorCode?: string | null; livenessState?: string | null } | null | undefined): boolean {
return run?.livenessState === "blocked" && isAiAuthenticationFailure(run.errorCode);
export function isAiAuthenticationBlocked(run: (AuthenticationFailure & { livenessState?: string | null }) | null | undefined): boolean {
return run?.livenessState === "blocked" && isAiAuthenticationRepairable(run);
}
export function aiBindingForAuthRecovery(
adapterType: string,
config: Record<string, unknown>,
failure?: AuthenticationFailure,
): AiConnectionBinding | undefined {
const missingProvider = failure ? missingPersonalAiCredentialProvider(failure) : undefined;
for (const provider of AI_PROVIDERS) {
if (missingProvider && missingProvider !== provider) continue;
const binding = { provider, method: AI_CONNECTION_CAPABILITIES[provider].methods.subscription ? "subscription" : "api_key", mode: "responsible_user" } as const;
if (isAiConnectionCompatible(binding, adapterType, config.model, config.provider, config.acpxAgent)) return binding;
}
@@ -1,4 +1,5 @@
import { connectionIntentService } from "./connection-intents.js";
import { isAiConnectionConfigurationFailure } from "./ai-auth-failure.js";
import { and, eq, isNull, lte, asc, notInArray, desc, sql } from "drizzle-orm";
import { connectionIntentDeliveries, issueThreadInteractions, issues, agentWakeupRequests, companyMemberships, heartbeatRuns, chatConversations, chatEndpoints, type Db } from "@paperclipai/db";
import type { heartbeatService } from "./heartbeat.js";
@@ -79,9 +80,8 @@ export function connectionIntentDeliveryService(db: Db, heartbeat: Pick<Heartbea
eq(heartbeatRuns.companyId, issue.companyId),
sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${issue.id}`,
)).orderBy(desc(heartbeatRuns.createdAt)).limit(1);
const gap = latest?.resultJson?.configurationIncomplete as { reason?: string } | undefined;
if (latest?.id !== loaded.interaction.sourceRunId || latest.status !== "failed"
|| latest.errorCode !== "configuration_incomplete" || gap?.reason !== "ai_connection_unavailable") return null;
|| !isAiConnectionConfigurationFailure(latest)) return null;
// Restricted external chat retries require their original chat provenance.
// Their existing Try again path owns that authorization and delivery.
const [restrictedChat] = await tx.select({ id: chatConversations.id }).from(chatConversations)
+7 -6
View File
@@ -6,7 +6,7 @@ import { createHash } from "node:crypto";
import { logActivity } from "./activity-log.js";
import { aiConnectionService } from "./ai-connections.js";
import { aiConnectionBindingSchema, aiConnectionRouterBindingSchema } from "@paperclipai/shared";
import { aiBindingForAuthRecovery, isAiAuthenticationFailure } from "./ai-auth-failure.js";
import { aiBindingForAuthRecovery, isAiAuthenticationFailure, isAiAuthenticationRepairable } from "./ai-auth-failure.js";
import { and, eq, desc, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@@ -162,6 +162,7 @@ export function connectionIntentService(db: Db) {
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
errorCode: heartbeatRuns.errorCode,
resultJson: heartbeatRuns.resultJson,
responsibleUserId: heartbeatRuns.responsibleUserId,
activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
contextSnapshot: heartbeatRuns.contextSnapshot,
@@ -176,7 +177,7 @@ export function connectionIntentService(db: Db) {
|| run.agentId !== claims.sub
|| (!run.activeIdentityContextId && run.responsibleUserId !== claims.responsible_user_id)
) throw forbidden("Runtime tool token does not match its heartbeat run");
if (failedAuthRun ? run.status !== "failed" || !isAiAuthenticationFailure(run.errorCode) : run.status !== "running") {
if (failedAuthRun ? run.status !== "failed" || !isAiAuthenticationRepairable(run) : run.status !== "running") {
throw forbidden("Runtime tool token is no longer active");
}
if (run.activeIdentityContextId && !failedAuthRun) {
@@ -854,8 +855,8 @@ export function connectionIntentService(db: Db) {
eq(heartbeatRuns.agentId, payload.requestingAgentId),
));
const [agent] = await db.select().from(agents).where(and(eq(agents.id, payload.requestingAgentId), eq(agents.companyId, loaded.issue.companyId)));
if (source?.status === "failed" && isAiAuthenticationFailure(source.errorCode) && !source.contextSnapshot?.aiConnection && agent && !agent.runtimeConfig.aiConnection) {
managed = await managedAgent(loaded.issue.companyId, agent.id, app.slug, { fallback: aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig) });
if (source?.status === "failed" && isAiAuthenticationRepairable(source) && !source.contextSnapshot?.aiConnection && agent && !agent.runtimeConfig.aiConnection) {
managed = await managedAgent(loaded.issue.companyId, agent.id, app.slug, { fallback: aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig, source) });
}
}
if (payload.purpose === "ai" && !managed) throw conflict("The agent’s AI configuration changed. Start a new execution.");
@@ -1165,7 +1166,7 @@ export function connectionIntentService(db: Db) {
// Controller-only entry point. Runtime tokens still require a running run.
requestForRunAuthFailure: async (runId: string) => {
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
if (!run || run.status !== "failed" || !isAiAuthenticationFailure(run.errorCode) || !run.responsibleUserId) return null;
if (!run || run.status !== "failed" || !isAiAuthenticationRepairable(run) || !run.responsibleUserId) return null;
const context = await loadRunContext({ sub: run.agentId, company_id: run.companyId, run_id: run.id, responsible_user_id: run.responsibleUserId }, true);
const [latest] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, run.companyId),
@@ -1178,7 +1179,7 @@ export function connectionIntentService(db: Db) {
const router = aiConnectionRouterBindingSchema.safeParse(agent.runtimeConfig.aiConnection).data;
const selected = router ? record(run.contextSnapshot?.aiRouterSelection) : null;
const managed = router && selected ? await managedAgent(run.companyId, run.agentId, String(record(selected.binding)?.provider), { sourceRunId: run.id }) : null;
const binding = router ? managed?.binding : saved ?? aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig);
const binding = router ? managed?.binding : saved ?? aiBindingForAuthRecovery(agent.adapterType, agent.adapterConfig, run);
if (!binding) return null;
const attribution = record(run.contextSnapshot?.aiConnection);
if (attribution && attribution.provider !== binding.provider) return null;
+2 -2
View File
@@ -1,4 +1,4 @@
import { isAiAuthenticationFailure } from "./ai-auth-failure.js";
import { isAiAuthenticationRepairable } from "./ai-auth-failure.js";
import type { HeartbeatRunStatus, IssueStatus, RunLivenessState } from "@paperclipai/shared";
export type RunLivenessActionability =
@@ -323,7 +323,7 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL
return output("needs_followup", input.errorCode ? `Run interrupted (${input.errorCode})` : "Run interrupted");
}
if (input.runStatus === "failed" && input.authenticationRepairRequested && isAiAuthenticationFailure(input.errorCode)) {
if (input.runStatus === "failed" && input.authenticationRepairRequested && isAiAuthenticationRepairable(input)) {
return output("blocked", "Waiting for provider authentication repair", "Complete the connection request to continue this task");
}
@@ -0,0 +1,76 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { json } from "./agent-chat.shared";
test("missing onboarding key offers personal Claude setup in chat and resumes once", async ({ page, request }) => {
test.setTimeout(120_000);
const root = await mkdtemp(path.join(os.tmpdir(), "personal-ai-recovery-"));
const original = await json(await request.get("/api/instance/settings/experimental"));
const company = await json(await request.post("/api/companies", { data: { name: `Personal AI recovery ${Date.now()}` } }));
try {
await json(await request.patch("/api/instance/settings/experimental", { data: { enableAgentChat: true, enableClassicTaskInterface: false } }));
const definition = await json(await request.post(`/api/companies/${company.id}/user-secret-definitions`, {
data: { key: "ANTHROPIC_API_KEY", name: "ANTHROPIC_API_KEY for onboarding" },
}));
await writeFile(path.join(root, "continued"), "ready");
const agent = await json(await request.post(`/api/companies/${company.id}/agents`, { data: {
name: "Chief of Staff", role: "general", adapterType: "claude_local",
adapterConfig: { engine: "acp", cwd: root, stateDir: path.join(root, "state"),
agentCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve("scripts/mcp-fixtures/servers/acp-stop-agent.mjs"))}`,
env: { ANTHROPIC_API_KEY: { type: "user_secret_ref", key: definition.key, version: "latest", required: true },
PAPERCLIP_STOP_FIXTURE_ROOT: root } },
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } },
} }));
const chatPath = `/api/companies/${company.id}/chats/${agent.id}`;
await page.goto(`/${company.issuePrefix}/chats/${agent.id}`);
await page.getByTestId("task-chat-composer-input").locator('[contenteditable="true"]').fill("Say hello.");
await page.getByTestId("task-chat-composer-send").click();
await expect(page.getByText("Connect your Claude account", { exact: true })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("Chief of Staff needs your own AI connection.", { exact: false })).toBeVisible();
await expect(page.getByRole("button", { name: "Try again", exact: true })).toHaveCount(0);
const chat = await json(await request.get(chatPath));
// Real pre-dispatch failure, not a seeded card or mocked Paperclip response.
const before = await json(await request.get(`/api/companies/${company.id}/heartbeat-runs`));
expect(before).toHaveLength(1);
const failed = await json(await request.get(`/api/heartbeat-runs/${before[0].id}`));
expect(failed).toMatchObject({ status: "failed", errorCode: "configuration_incomplete", responsibleUserId: "local-board",
resultJson: { configurationIncomplete: { reason: "secret_binding_missing", missingBindings: [expect.objectContaining({ errorCode: "user_secret_missing" })] } } });
const sourceRunId = before[0].id;
await test.info().attach("missing-personal-ai-card", { body: await page.screenshot(), contentType: "image/png" });
await page.getByRole("button", { name: "Connect Claude", exact: true }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await page.getByRole("button", { name: "Use API key instead", exact: true }).click();
await page.getByPlaceholder("Enter API key here").fill("paperclip-e2e-personal-claude-key");
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(page.getByRole("button", { name: "Use connection and continue", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Use connection and continue", exact: true }).click();
await expect(page.getByText("Answered the pending follow-up once.", { exact: false })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("Claude connected", { exact: true })).toBeVisible();
await expect.poll(async () => {
const after = await json(await request.get(`/api/companies/${company.id}/heartbeat-runs`));
return after.filter((run: { id: string; status: string }) => run.id !== sourceRunId).map((run: { status: string }) => run.status);
}).toEqual(["succeeded"]);
const updatedAgent = await json(await request.get(`/api/agents/${agent.id}`));
expect(updatedAgent.runtimeConfig.aiConnection).toMatchObject({ provider: "anthropic", mode: "responsible_user" });
const { connections: accounts } = await json(await request.get(`/api/companies/${company.id}/ai-connections`));
expect(accounts).toHaveLength(1);
expect(accounts[0]).toMatchObject({ provider: "anthropic", ownership: "personal", ownerUserId: "local-board", status: "connected" });
const after = await json(await request.get(`/api/companies/${company.id}/heartbeat-runs`));
const resumed = await json(await request.get(`/api/heartbeat-runs/${after.find((run: { id: string }) => run.id !== sourceRunId).id}`));
expect(resumed.contextSnapshot.aiConnection).toMatchObject({ connectionId: accounts[0].id, responsibleUserId: "local-board" });
expect((await json(await request.get(`/api/issues/${chat.id}`))).status).toBe("in_review");
await page.reload();
await expect(page.getByText("Answered the pending follow-up once.", { exact: false })).toBeVisible();
await expect(page.getByRole("button", { name: "Connect Claude", exact: true })).toHaveCount(0);
await test.info().attach("personal-ai-repair-resumed", { body: await page.screenshot(), contentType: "image/png" });
} finally {
await Promise.allSettled([
request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } }),
request.patch("/api/instance/settings/experimental", { data: original }),
]);
await rm(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,16 @@
// Only the upstream credential check is simulated. The vault, card, adoption,
// runtime credential selection, and continuation delivery use real Paperclip APIs.
if (process.env.NODE_ENV === "test") {
const realFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url);
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
if (url.hostname === "api.anthropic.com"
&& headers.get("x-api-key") === "paperclip-e2e-personal-claude-key") {
return url.pathname === "/v1/models"
? Response.json({ data: [], has_more: false })
: Response.json({ error: "Unsupported fixture Anthropic request" }, { status: 422 });
}
return realFetch(input, init);
};
}
+1 -1
View File
@@ -79,7 +79,7 @@ export default defineConfig({
...process.env,
NODE_ENV: "test",
PAPERCLIP_UI_DEV_MIDDLEWARE: "false",
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${path.resolve(import.meta.dirname, "fixtures/agent-chat-github.mjs")}`,
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${path.resolve(import.meta.dirname, "fixtures/agent-chat-github.mjs")} --import=${path.resolve(import.meta.dirname, "fixtures/ai-connection-provider.mjs")}`,
PORT: String(PORT),
PAPERCLIP_OPEN_ON_LISTEN: "false",
PAPERCLIP_API_URL: BASE_URL,
+17
View File
@@ -16,6 +16,7 @@ import type {
} from "@paperclipai/shared";
import { heartbeatsApi } from "@/api/heartbeats";
import { nativeRunEventsToTranscript } from "./transcript/native-run-events";
import { pendingConnectionIntentInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import type { HeartbeatRunEvent } from "@paperclipai/shared";
const transcriptState = vi.hoisted(() => ({
@@ -1010,6 +1011,22 @@ describe("TaskChatThread runtime transcript selection", () => {
expect(container.textContent).not.toContain("Workspace restore failed");
});
it("directs a missing personal AI credential to its card without offering a premature retry", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} issueStatus="blocked"
onRetryFailedRun={vi.fn()} interactions={[{
...pendingConnectionIntentInteraction, sourceRunId: "missing-ai-run",
payload: { ...pendingConnectionIntentInteraction.payload, purpose: "ai" },
}]} linkedRuns={[{
runId: "missing-ai-run", runtimeMode: "legacy", status: "failed", errorCode: "configuration_incomplete",
agentId: "agent-1", agentName: "Chief of Staff", adapterType: "claude_local",
createdAt: "2026-08-25T18:00:00.000Z", startedAt: null, finishedAt: "2026-08-25T18:00:02.000Z",
}]} />);
expect(container.textContent).toContain("AI connection needed");
expect(container.textContent).toContain("Use the connection card below to continue.");
expect(container.textContent).not.toContain("The selected AI account is unavailable");
expect(container.querySelector('[data-testid="task-chat-run-failed-try-again"]')).toBeNull();
});
it("projects the saved Plan inline at its native write_document boundary", () => {
planState.data = planDocument({
updatedAt: new Date("2026-08-25T18:00:02.000Z"),
+4 -3
View File
@@ -1722,7 +1722,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
? workspaceRestoreMarkerDetail({ result: meta?.resultJson, savedPlan, hasResponse: sourceHasPresentationComment || Boolean(acceptedSummary) })
: aiRequest
? aiRequest.status === "pending"
? "The selected AI account is unavailable. Fix it in the connection card."
? "Use the connection card below to continue."
: "This run stopped because its AI account was unavailable."
: source.status === "cancelled"
? code === "execution_reconciliation_required"
@@ -1742,15 +1742,16 @@ export function TaskChatThread(props: TaskChatThreadProps) {
id,
kind: "marker",
variant: "interrupted",
label: restoreFailed ? "Workspace restore failed" : source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed",
label: restoreFailed ? "Workspace restore failed" : aiRequest?.status === "pending" ? "AI connection needed" : source.status === "cancelled" ? (meta?.startedAt ? "Stopped" : "Couldn't start") : "Run failed",
runId: source.status === "cancelled" ? undefined : source.id,
...(aiRequest?.status === "pending" ? { retryable: false } : {}),
...(restoreFailed ? {
retryable: meta?.resultJson?.workspaceRestoreFailure !== "restore_unsafe_archive",
collapsible: true,
runHref: meta?.agentId ? `/agents/${encodeURIComponent(agentMap?.get(meta.agentId)?.urlKey ?? meta.agentId)}/runs/${encodeURIComponent(source.id)}` : undefined,
planHref: savedPlan ? "#document-plan" : undefined,
} : {}),
tone: source.status === "cancelled" ? "neutral" : "error",
tone: source.status === "cancelled" || aiRequest?.status === "pending" ? "neutral" : "error",
detail,
},
});
@@ -228,7 +228,6 @@ describe("AgentProviderConnection reuse", () => {
intent, initialMethod: "api_key", fixedMethod: false, onComplete,
nameForMethod: method => defaultAiConnectionName("dotta", provider, method),
});
openProvider();
expect(host.querySelector('input[type="password"]')).not.toBeNull();
click("Use subscription instead");
const subscription = mocks.loginPanel.mock.calls.at(-1)![0].aiConnection;
@@ -87,7 +87,8 @@ export function AgentProviderConnection({
};
const [methodChoice, setMethod] = useState<"subscription" | "api" | null>(managedAccount?.initialMethod === "api_key" ? "api" : managedAccount ? "subscription" : null);
const [advanced, setAdvanced] = useState(false);
const [opened, setOpened] = useState(Boolean(advancedConnection));
// Connections already selected the provider before showing this step.
const [opened, setOpened] = useState(Boolean(advancedConnection || managedAccount));
const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
const [loginPhase, setLoginPhase] = useState<"preparing" | "ready" | "waiting" | "connecting">("preparing");
const phaseBeforeSubmit = useRef<"ready" | "waiting">("ready");
@@ -509,9 +509,11 @@ describe("AI repair inside the card", () => {
if (fails) adoptMock.mockRejectedValue(new Error("Connection test failed"));
else adoptMock.mockResolvedValue({ ...interaction, status: "accepted" });
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
await act(() => button("Connect OpenAI")!.click());
await act(() => button("Reconnect selected account")!.click());
expect(adoptMock).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("Use your OpenAI account");
expect(document.body.textContent).toContain("Your account is connected.");
expect(document.body.textContent).toContain("This replaces the agent’s existing authentication");
await act(() => button("Use connection and continue")!.click());
await flush();
@@ -525,14 +527,17 @@ describe("AI repair inside the card", () => {
setupOptionsMock.mockResolvedValue({ interaction, existingConnections: [], aiConnection: { provider, method: "api_key", mode: "responsible_user" } });
completeMock.mockResolvedValue({ ...interaction, status: "accepted" });
renderBody(interaction); await flush();
await act(() => button("Fix connection")!.click());
const providerName = provider === "anthropic" ? "Claude" : "OpenAI";
expect(document.body.textContent).toContain(`Connect your ${providerName} account`);
expect(document.body.textContent).toContain("needs your own AI connection");
await act(() => button(`Connect ${providerName}`)!.click());
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(document.querySelector('[data-testid="shared-connection-setup"]')).toBeNull();
expect(document.querySelector('[data-testid="shared-ai-credentials"]')).not.toBeNull();
expect(credentialRender.mock.lastCall![0]).toMatchObject({ provider, hideName: true, ownership: "personal", agentIds: [interaction.payload.requestingAgentId], allAgents: false });
expect(credentialRender.mock.lastCall![0].connectionId).toBeUndefined();
expect(credentialRender.mock.lastCall![0].fixedMethod).toBeUndefined();
expect(document.body.textContent).toContain("This task can’t run until");
expect(document.body.textContent).toContain("the task will resume automatically");
await act(() => button("Reconnect selected account")!.click());
expect(completeMock).toHaveBeenCalledWith(interaction.id, "new-ai-account");
});
@@ -8,7 +8,7 @@ import {
RotateCcw,
XCircle,
} from "lucide-react";
import type { AiAuthMethod, ConnectionIntentInteraction } from "@paperclipai/shared";
import { AI_CONNECTION_CAPABILITIES, aiProviderSchema, type AiAuthMethod, type ConnectionIntentInteraction } from "@paperclipai/shared";
import { AgentMailIntentSetup } from "./AgentMailIntentSetup";
import { connectionIntentsApi } from "@/api/connection-intents";
import { aiConnectionsApi } from "@/api/ai-connections";
@@ -64,6 +64,8 @@ export function ConnectionIntentInteractionBody({
);
const isPending = interaction.status === "pending";
const isAi = interaction.payload.purpose === "ai";
const provider = isAi ? aiProviderSchema.safeParse(interaction.payload.serviceSlug).data : undefined;
const serviceName = provider ? AI_CONNECTION_CAPABILITIES[provider].name : interaction.payload.serviceName;
const isEmail = interaction.payload.purpose === "channel" && interaction.payload.serviceSlug === "agentmail";
const accessRequest = interaction.payload.accessRequest;
const agentQuery = useQuery({
@@ -241,7 +243,7 @@ export function ConnectionIntentInteractionBody({
interaction.status === "accepted"
? {
icon: CheckCircle2,
title: accessRequest ? `${interaction.payload.serviceName} access granted` : interaction.payload.upstreamService ? "External provider connected" : `${interaction.payload.serviceName} connected`,
title: accessRequest ? `${serviceName} access granted` : interaction.payload.upstreamService ? "External provider connected" : `${serviceName} connected`,
body: accessRequest ? null : interaction.payload.upstreamService ? `${interaction.payload.requestingAgentName} can now verify and authorize ${interaction.payload.upstreamService.name} through this provider. The app is not yet verified.` : isAi ? "This agent can now use the connection." : `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`,
}
: interaction.status === "rejected"
@@ -378,11 +380,13 @@ export function ConnectionIntentInteractionBody({
renderSetup ? renderSetup(setupProps) : <ConnectionSetupFlow {...setupProps} />
) : null;
const aiConnection = setupQuery.data?.aiConnection;
const needsOwnAiConnection = isAi && (!repair || setupQuery.data?.aiConnectionRequiresAdoption) && aiConnection?.mode === "responsible_user";
const aiProviderName = aiConnection ? AI_CONNECTION_CAPABILITIES[aiConnection.provider].name : serviceName;
const inlineContent = setupQuery.isLoading || setupQuery.isError ? setupContent
: readyForAdoption ? <div className="space-y-3">
<p className="text-sm">
Use Connections for {interaction.payload.requestingAgentName}? This replaces the agent’s existing authentication
with the responsible person’s {interaction.payload.serviceName} connection. The model stays the same.
Use your {aiProviderName} connection for {interaction.payload.requestingAgentName}? This replaces the agent’s existing authentication
with each responsible person’s own account. The model stays the same.
</p>
<Button disabled={adoptMutation.isPending} onClick={() => adoptMutation.mutate(readyForAdoption)}>
{adoptMutation.isPending ? "Checking connection…" : "Use connection and continue"}
@@ -450,10 +454,14 @@ export function ConnectionIntentInteractionBody({
/>
<div>
<p className="font-medium text-foreground">
{isAi ? `${interaction.payload.serviceName} authentication required` : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`}
{readyForAdoption ? `Use your ${aiProviderName} account` : needsOwnAiConnection ? `Connect your ${aiProviderName} account` : isAi ? `${aiProviderName} authentication required` : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{interaction.payload.purpose === "ai"
{readyForAdoption
? `Your account is connected. Use it for ${interaction.payload.requestingAgentName} to resume this task.`
: needsOwnAiConnection
? `${interaction.payload.requestingAgentName} needs your own AI connection. Connect here and the task will resume automatically.`
: interaction.payload.purpose === "ai"
? "This task can’t run until the agent has a valid AI connection. Connect here and the task will resume automatically."
: isEmail ? "Connect AgentMail to create an email address for this agent."
: "Connect your identity or reuse an eligible connection. Access is added only for this agent."}
@@ -491,7 +499,7 @@ export function ConnectionIntentInteractionBody({
Not now
</Button>}
{isAi ? <Button type="button" disabled={completeMutation.isPending || adoptMutation.isPending || selectAiAccountMutation.isPending} onClick={() => open ? closeSetup() : setOpen(true)}>
<Plug className="h-4 w-4" />{open ? "Close setup" : "Fix connection"}
<Plug className="h-4 w-4" />{open ? "Close setup" : readyForAdoption ? "Continue setup" : needsOwnAiConnection ? `Connect ${aiProviderName}` : "Fix connection"}
</Button> : <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button">