mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs need credentials for a specific provider and sign-in method. > - Connections already owns accounts, grants, and access permissions. > - AI authentication should use those same boundaries. > - This pull request adds the storage, API, adoption, and runtime foundation. > - Legacy agents keep their authentication until they explicitly adopt a managed connection. ## Linked Issues or Issue Description **Problem or motivation** AI credentials are configured separately from Connections. Agents cannot consistently reuse a responsible user's account or a permitted shared account. **Proposed solution** Manage AI accounts with the existing Connections grants and permissions. Keep model and harness selection independent from credential selection. Preserve legacy authentication until validated adoption. **Alternatives considered** A separate credential registry would duplicate ownership and access policy. Automatic fallback would risk using the wrong account. **Roadmap alignment** This extends the shipped Apps, multi-user, secrets, and agent-runtime capabilities. The maintainer requested the feature and reviewed the UI. Related groundwork: #11899 (connection permissions), #10910 (connection wizard), #11692 (Claude subscription profiles), and #11854 (Codex account rotation). ## What Changed - Add AI-purpose/runtime-auth contracts and an additive, idempotent migration. - Add Claude, OpenAI, OpenRouter, and Grok provider capabilities and catalog entries. - Store credentials on grants. Resolve responsible-user defaults or explicit permitted grants. - Isolate managed credentials and provider sessions across accounts. Block missing credentials without ambient fallback. - Keep imported legacy secrets unchanged during reconnect. Use independent local Codex/Grok sign-in attempts for rotating credentials. - Add authorization, migration, concurrent refresh, retry, cancellation, and legacy-compatibility tests. This is part 1 of a two-PR stack. The app UI follows in #13248. Merge the foundation first. ## Verification - Updated against master `04e364236`, preserving upstream provider login and connector workflows. - Full workspace typecheck, production build, Storybook build, and token gates passed on the integrated branch. Final local-login changes passed 59 focused tests; new-agent and inbox regression suites passed 63 tests. - Browser checks verified automatic local Claude account detection, resumable Codex login commands, retry, focus restoration, and desktop/phone layouts. Commands create their isolated directory before invoking the CLI. - All current-head CI checks passed on `2a996560a`, including all server/workspace tests, browser shards, runner verification, typecheck, build, and canary dry run. Greptile reviewed that commit at 5/5 with no unresolved threads. Earlier local full-suite attempts hit the Mac PostgreSQL shared-memory limit; the complete suites passed in CI. - Renumbered the additive AI migration to `0276` after upstream migrations and regenerated its snapshot. Existing legacy agents retain their configuration. - Added local login status checks, owner-scoped retry, managed OpenCode remote homes, credential-aware model discovery, and task connection-repair delivery. ## Risks - Managed credential failures intentionally block execution. They do not restore legacy fallback. - Preview-era copied Codex/Grok subscriptions require independent reconnect. - The integrated branch has live provider acceptance coverage. This update verifies local Claude detection and Codex API-key task repair; it does not add a new subscription authorization/refresh or Daytona stress pass. - Runtime-auth connections must stay excluded from tool and channel handling. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser testing. The exact runtime model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
114 lines
5.1 KiB
TypeScript
114 lines
5.1 KiB
TypeScript
import { and, eq, inArray } from "drizzle-orm";
|
|
import { type Db, toolConnections, companySecretBindings, connectionGrants, companySecrets, userSecretDefinitions } from "@paperclipai/db";
|
|
import type { ToolCredentialSecretRef } from "@paperclipai/shared";
|
|
import { secretService } from "./secrets.js";
|
|
function credentialRefConfigPath(ref: { name: string }): string { return ref.name.startsWith("credentials.") ? ref.name : `credentials.${ref.name}`; }
|
|
export async function syncConnectionCredentialBindings(
|
|
db: Db | Parameters<Parameters<Db["transaction"]>[0]>[0],
|
|
connection: typeof toolConnections.$inferSelect,
|
|
grantSecretRefs: ToolCredentialSecretRef[] = [],
|
|
dbClient: Pick<Db, "select" | "insert" | "update" | "delete"> = db,
|
|
) {
|
|
const secrets = secretService(db);
|
|
await dbClient
|
|
.delete(companySecretBindings)
|
|
.where(
|
|
and(
|
|
eq(companySecretBindings.companyId, connection.companyId),
|
|
eq(companySecretBindings.targetType, "tool_connection"),
|
|
eq(companySecretBindings.targetId, connection.id),
|
|
),
|
|
);
|
|
// A metadata edit or pause/resume must retain declarations for every
|
|
// active personal/dedicated grant, not just connection-owned credentials.
|
|
const activeGrants = await dbClient.select({ refs: connectionGrants.credentialSecretRefs })
|
|
.from(connectionGrants).where(and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.status, "active"),
|
|
));
|
|
const rawBindings = [
|
|
...connection.credentialRefs.map((ref) => ({
|
|
secretId: ref.secretId,
|
|
configPath: credentialRefConfigPath(ref),
|
|
projectionClass: "unclassified",
|
|
projectionAllowlistKey: null,
|
|
required: true,
|
|
label: null,
|
|
})),
|
|
...[...connection.credentialSecretRefs, ...grantSecretRefs, ...activeGrants.flatMap((grant) => grant.refs)].map((ref) => ({
|
|
secretId: ref.secretId,
|
|
configPath: ref.configPath,
|
|
projectionClass: ref.projectionClass ?? "unclassified",
|
|
projectionAllowlistKey: ref.projectionAllowlistKey ?? null,
|
|
required: ref.required ?? true,
|
|
label: ref.label ?? null,
|
|
})),
|
|
];
|
|
// Organization grants can mirror connection-owned credentials, and more
|
|
// than one personal grant can reference the same client registration.
|
|
// Binding rows are unique per secret/config path, so collapse those mirrors
|
|
// before replacing the durable projection declarations.
|
|
const bindings = [...new Map(rawBindings.map((ref) => [
|
|
`${ref.secretId}:${ref.configPath}`,
|
|
ref,
|
|
])).values()];
|
|
const secretRows = bindings.length > 0
|
|
? await dbClient.select({
|
|
id: companySecrets.id,
|
|
scope: companySecrets.scope,
|
|
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
|
}).from(companySecrets).where(and(
|
|
eq(companySecrets.companyId, connection.companyId),
|
|
inArray(companySecrets.id, [...new Set(bindings.map((ref) => ref.secretId))]),
|
|
))
|
|
: [];
|
|
const secretById = new Map(secretRows.map((row) => [row.id, row]));
|
|
const definitionIds = [...new Set(secretRows.flatMap((row) => row.userSecretDefinitionId ? [row.userSecretDefinitionId] : []))];
|
|
const definitions = definitionIds.length > 0
|
|
? await dbClient.select({ id: userSecretDefinitions.id, key: userSecretDefinitions.key })
|
|
.from(userSecretDefinitions)
|
|
.where(and(
|
|
eq(userSecretDefinitions.companyId, connection.companyId),
|
|
inArray(userSecretDefinitions.id, definitionIds),
|
|
))
|
|
: [];
|
|
const definitionKeyById = new Map(definitions.map((row) => [row.id, row.key]));
|
|
const userDeclarations = [...new Map(bindings.flatMap((ref) => {
|
|
const secret = secretById.get(ref.secretId);
|
|
const definitionKey = secret?.scope === "user" && secret.userSecretDefinitionId
|
|
? definitionKeyById.get(secret.userSecretDefinitionId)
|
|
: null;
|
|
return definitionKey
|
|
? [{
|
|
definitionKey,
|
|
configPath: ref.configPath,
|
|
envKey: ref.configPath,
|
|
versionSelector: "latest" as const,
|
|
required: ref.required,
|
|
label: ref.label,
|
|
}]
|
|
: [];
|
|
}).map((ref) => [`${ref.definitionKey}:${ref.configPath}`, ref])).values()];
|
|
await secrets.syncUserSecretDeclarationsForTarget(
|
|
connection.companyId,
|
|
{ targetType: "tool_connection", targetId: connection.id },
|
|
userDeclarations,
|
|
{ replaceAll: true, db: dbClient },
|
|
);
|
|
const companyBindings = bindings.filter((ref) => secretById.get(ref.secretId)?.scope !== "user");
|
|
if (companyBindings.length === 0) return;
|
|
await dbClient.insert(companySecretBindings).values(companyBindings.map((ref) => ({
|
|
companyId: connection.companyId,
|
|
secretId: ref.secretId,
|
|
targetType: "tool_connection" as const,
|
|
targetId: connection.id,
|
|
configPath: ref.configPath,
|
|
required: ref.required,
|
|
label: ref.label,
|
|
projectionClass: ref.projectionClass,
|
|
projectionAllowlistKey: ref.projectionAllowlistKey,
|
|
})));
|
|
}
|
|
|