mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02: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>
150 lines
4.9 KiB
TypeScript
150 lines
4.9 KiB
TypeScript
import type { Db } from "@paperclipai/db";
|
|
import { agents } from "@paperclipai/db";
|
|
import { reconcileManagedCodexHome } from "@paperclipai/adapter-codex-local/server";
|
|
import { eq } from "drizzle-orm";
|
|
import { logger } from "../middleware/logger.js";
|
|
|
|
export interface CodexAuthReconciliationSummary {
|
|
scanned: number;
|
|
seeded: number;
|
|
alreadySeeded: number;
|
|
externalOverride: number;
|
|
noManagedHome: number;
|
|
sourceAuthMissing: number;
|
|
failed: number;
|
|
seededAgentIds: string[];
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Extracts a literal (non-secret) env value. Adapter env bindings persist either
|
|
* as a bare string or as a `{ type: "plain", value }` object; secret bindings
|
|
* are intentionally NOT resolved here (we never write an unresolved secret
|
|
* placeholder into auth.json). Mirrors the server-side env-binding extraction in
|
|
* routes/agents.ts.
|
|
*/
|
|
function readPlainEnvValue(value: unknown): string | null {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
const record = asRecord(value);
|
|
if (record?.type !== "plain") return null;
|
|
return readPlainEnvValue(record.value);
|
|
}
|
|
|
|
type ApiKeyBinding =
|
|
| { kind: "plain"; value: string }
|
|
| { kind: "secret" }
|
|
| { kind: "none" };
|
|
|
|
/**
|
|
* Classifies an `OPENAI_API_KEY` env binding so reconciliation can tell the
|
|
* difference between three states it must treat differently:
|
|
* - `plain`: a literal value we can write into auth.json.
|
|
* - `secret`: a secret binding (e.g. `{ type: "secret_ref", ... }`) we cannot
|
|
* resolve at startup; the resolved value may already exist on disk from a
|
|
* prior execute-time run, so reconciliation must not clobber it.
|
|
* - `none`: no key configured (chatgpt-subscription mode); seed the shared
|
|
* auth symlink.
|
|
*/
|
|
function classifyApiKeyBinding(value: unknown): ApiKeyBinding {
|
|
const plain = readPlainEnvValue(value);
|
|
if (plain) return { kind: "plain", value: plain };
|
|
const record = asRecord(value);
|
|
if (record && typeof record.type === "string" && record.type !== "plain") {
|
|
return { kind: "secret" };
|
|
}
|
|
return { kind: "none" };
|
|
}
|
|
|
|
/**
|
|
* Startup backfill: seed `auth.json` into any already-
|
|
* isolated `codex_local` managed home that was created (by the #8272 isolation
|
|
* guard) before the Phase 1 seeding fix landed. Phase 1 seeds at execute time;
|
|
* this repairs persisted homes proactively so a stranded agent recovers without
|
|
* waiting to run and without a manual symlink. Idempotent and safe to re-run on
|
|
* every boot: a home that already has valid auth is a no-op.
|
|
*/
|
|
export async function reconcileCodexLocalManagedHomesOnStartup(
|
|
db: Db,
|
|
): Promise<CodexAuthReconciliationSummary> {
|
|
const summary: CodexAuthReconciliationSummary = {
|
|
scanned: 0,
|
|
seeded: 0,
|
|
alreadySeeded: 0,
|
|
externalOverride: 0,
|
|
noManagedHome: 0,
|
|
sourceAuthMissing: 0,
|
|
failed: 0,
|
|
seededAgentIds: [],
|
|
};
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: agents.id,
|
|
companyId: agents.companyId,
|
|
adapterConfig: agents.adapterConfig,
|
|
runtimeConfig: agents.runtimeConfig,
|
|
})
|
|
.from(agents)
|
|
.where(eq(agents.adapterType, "codex_local"));
|
|
|
|
for (const row of rows) {
|
|
if (row.runtimeConfig?.aiConnection) continue;
|
|
summary.scanned += 1;
|
|
const env = asRecord(asRecord(row.adapterConfig)?.env);
|
|
const configuredCodexHome = env ? readPlainEnvValue(env.CODEX_HOME) : null;
|
|
const apiKeyBinding = classifyApiKeyBinding(env?.OPENAI_API_KEY);
|
|
|
|
try {
|
|
const result = await reconcileManagedCodexHome({
|
|
companyId: row.companyId,
|
|
configuredCodexHome,
|
|
apiKey: apiKeyBinding.kind === "plain" ? apiKeyBinding.value : null,
|
|
apiKeySecretBound: apiKeyBinding.kind === "secret",
|
|
});
|
|
switch (result.status) {
|
|
case "seeded":
|
|
summary.seeded += 1;
|
|
summary.seededAgentIds.push(row.id);
|
|
logger.info(
|
|
{ agentId: row.id, companyId: row.companyId, home: result.home },
|
|
"seeded auth into already-isolated codex_local managed home",
|
|
);
|
|
break;
|
|
case "already_seeded":
|
|
summary.alreadySeeded += 1;
|
|
break;
|
|
case "external_override":
|
|
summary.externalOverride += 1;
|
|
break;
|
|
case "no_managed_home":
|
|
summary.noManagedHome += 1;
|
|
break;
|
|
case "source_auth_missing":
|
|
summary.sourceAuthMissing += 1;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
summary.failed += 1;
|
|
logger.warn(
|
|
{
|
|
agentId: row.id,
|
|
companyId: row.companyId,
|
|
home: configuredCodexHome,
|
|
err: err instanceof Error ? err.message : String(err),
|
|
},
|
|
"failed to reconcile codex_local managed home on startup",
|
|
);
|
|
}
|
|
}
|
|
|
|
return summary;
|
|
}
|