mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
## Thinking Path > - Paperclip manages AI agents and their credentials. > - AI Connections must preserve account ownership from login through execution. > - A subscription environment check can pass and leave a session advisory lock on a pooled database backend. > - The next execution then reports that the subscription is busy. > - Onboarding can also lose its completion callback when the connection list refreshes before the login poll. > - This change keeps each credential lease in one transaction and keeps the login controller mounted until completion. ## Linked Issues or Issue Description Refs: #13247, #13248, #13344. **What happened?** A real hosted subscription login saved successfully and passed its environment check. The first task then failed with an AI connection busy error. A connection-list update could also leave onboarding on Connecting. **Expected behavior** A completed environment check releases its credential lease. A saved login completes onboarding without another sign-in. **Steps to reproduce** 1. Run Paperclip with a transaction-pooling PostgreSQL proxy. 2. Connect a Claude subscription through onboarding and reuse it for an agent. 3. Start a task after its environment check succeeds. 4. For the UI race, refresh the managed connection list before the login completion poll. ## What Changed - Hold the grant lock inside one transaction. Roll back on cleanup or failed acquisition. - Disable the lease transaction's idle timeout so long provider executions retain the lock. - Keep the onboarding login controller mounted while its authorization URL is active; restore saved-account retry if later agent creation fails. - Add lock-lifetime and Claude/Codex completion-order regressions. - Document the pooling requirement. ## Verification - 104 focused connection and onboarding tests passed. - Workspace typecheck, production build, Storybook build, and token gates passed. - All 34 latest-head checks are terminal: 32 passed and two Storybook workflows skipped by their normal filters. CI covers all server/workspace/serialized test shards, browser tests, typecheck, build, runner verification, and canary packaging. The additional local full-suite run is still in progress. - Real browser sign-ins saved Claude and OpenAI subscriptions on both new and existing staging stacks. On the patched new stack, both providers completed tasks and immediate repeat executions with the same saved accounts. Read-only database inspection confirmed live transaction-scoped locks and no remaining locks after completion. On the final deployed head, both shared accounts on the existing stack also completed actual tasks. Both personal accounts passed a fresh environment check followed immediately by execution after a deployment restart. ## Risks - Each active subscription reserves one database connection and holds an otherwise idle transaction until cleanup. This is required to pin the backend through a transaction pool. - Old session locks from earlier versions may need operator cleanup after active runs drain. This change does not bypass existing locks. - No database migration, credential routing, or legacy authentication change. ## Model Used OpenAI GPT-6 in Codex, with code execution and browser tools. The exact deployment model ID and context-window size are not exposed to this task. ## 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>
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { unprocessable } from "../errors.js";
|
|
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { type Db, connectionGrants } from "@paperclipai/db";
|
|
import {
|
|
AI_CONNECTION_CAPABILITIES,
|
|
type AiConnectionBinding,
|
|
} from "@paperclipai/shared";
|
|
import { aiConnectionService } from "./ai-connections.js";
|
|
import { secretService } from "./secrets.js";
|
|
import { decideCodexAuthMerge } from "@paperclipai/adapter-codex-local/server";
|
|
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
|
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
|
|
import { decideGrokAuthMerge } from "@paperclipai/adapter-grok-local/server";
|
|
|
|
// Blank values intentionally override inherited credentials in CLI child environments.
|
|
export const AI_AUTH_ENV_KEYS = [
|
|
"ANTHROPIC_API_KEY",
|
|
"ANTHROPIC_AUTH_TOKEN",
|
|
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
"OPENAI_API_KEY",
|
|
"CODEX_API_KEY",
|
|
"OPENROUTER_API_KEY",
|
|
"XAI_API_KEY",
|
|
"GROK_API_KEY",
|
|
"CODEX_HOME",
|
|
"GROK_HOME",
|
|
"CLAUDE_CONFIG_DIR",
|
|
"OPENCODE_AUTH_JSON",
|
|
"OPENCODE_CONFIG_CONTENT",
|
|
"OPENCODE_CONFIG",
|
|
"OPENCODE_CONFIG_DIR",
|
|
"PAPERCLIP_OPENCODE_PROVIDERS",
|
|
"ANTHROPIC_BASE_URL",
|
|
"OPENAI_BASE_URL",
|
|
"XAI_BASE_URL",
|
|
"CLAUDE_CODE_USE_BEDROCK",
|
|
"CLAUDE_CODE_USE_VERTEX",
|
|
"CLAUDE_CODE_USE_FOUNDRY",
|
|
] as const;
|
|
export function stripAiAuthBindings(env: unknown): Record<string, unknown> {
|
|
const result = {
|
|
...(env && typeof env === "object" ? (env as Record<string, unknown>) : {}),
|
|
};
|
|
for (const key of AI_AUTH_ENV_KEYS)
|
|
if (
|
|
![
|
|
"ANTHROPIC_BASE_URL",
|
|
"OPENAI_BASE_URL",
|
|
"XAI_BASE_URL",
|
|
"CLAUDE_CODE_USE_BEDROCK",
|
|
"CLAUDE_CODE_USE_VERTEX",
|
|
"CLAUDE_CODE_USE_FOUNDRY",
|
|
"PAPERCLIP_OPENCODE_PROVIDERS",
|
|
].includes(key)
|
|
)
|
|
delete result[key];
|
|
return result;
|
|
}
|
|
export async function assertManagedAiProjectAuth(
|
|
config: Record<string, unknown>,
|
|
provider: AiConnectionBinding["provider"],
|
|
target?: AdapterExecutionTarget | null,
|
|
) {
|
|
const extraArgs = [
|
|
...(Array.isArray(config.extraArgs) ? config.extraArgs : []),
|
|
...(Array.isArray(config.args) ? config.args : []),
|
|
];
|
|
if (
|
|
extraArgs.some(
|
|
(arg) =>
|
|
typeof arg === "string" &&
|
|
/^(--config|-c|--settings|--setting-sources|--api-key|--auth-token)(=|$)/.test(
|
|
arg,
|
|
),
|
|
)
|
|
) {
|
|
throw unprocessable(
|
|
"Remove authentication/configuration overrides before selecting a managed AI connection",
|
|
{ code: "ai_connection_incompatible" },
|
|
);
|
|
}
|
|
const files =
|
|
provider === "anthropic"
|
|
? [".claude/settings.json", ".claude/settings.local.json"]
|
|
: provider === "openai"
|
|
? [".codex/config.toml"]
|
|
: [];
|
|
const pattern =
|
|
"apiKeyHelper|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|model_provider[[:space:]]*=|env_key[[:space:]]*=|experimental_bearer_token|cli_auth_credentials_store";
|
|
if (target?.kind === "remote" && files.length) {
|
|
// Only inspect for conflicting keys; never return configuration or credential values.
|
|
const result = await runAdapterExecutionTargetProcess(
|
|
`ai-auth-check-${Date.now()}`,
|
|
target,
|
|
"sh",
|
|
[
|
|
"-c",
|
|
`
|
|
directory=$1; pattern=$2; shift 2
|
|
while :; do
|
|
for relative in "$@"; do
|
|
file="$directory/$relative"
|
|
if test -f "$file"; then
|
|
grep -Eq "$pattern" "$file"
|
|
result=$?
|
|
if test "$result" -eq 0; then exit 42; fi
|
|
if test "$result" -ne 1; then exit 43; fi
|
|
fi
|
|
done
|
|
parent=$(dirname "$directory")
|
|
if test "$parent" = "$directory"; then break; fi
|
|
directory=$parent
|
|
done`,
|
|
"ai-auth-check",
|
|
target.remoteCwd,
|
|
pattern,
|
|
...files,
|
|
],
|
|
{
|
|
cwd: target.remoteCwd,
|
|
env: {},
|
|
timeoutSec: 15,
|
|
graceSec: 1,
|
|
onLog: async () => {},
|
|
},
|
|
);
|
|
if (result.exitCode !== 0)
|
|
throw unprocessable(
|
|
"The environment's project authentication settings must be checked before using this AI connection",
|
|
{ code: "ai_connection_incompatible" },
|
|
);
|
|
return;
|
|
}
|
|
let directory =
|
|
typeof config.cwd === "string" ? path.resolve(config.cwd) : process.cwd();
|
|
for (;;) {
|
|
for (const relative of files) {
|
|
try {
|
|
const content = await readFile(path.join(directory, relative), "utf8");
|
|
if (
|
|
/apiKeyHelper|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|model_provider\s*=|env_key\s*=|experimental_bearer_token|cli_auth_credentials_store/.test(
|
|
content,
|
|
)
|
|
) {
|
|
throw unprocessable(
|
|
"Project authentication settings conflict with the selected AI connection",
|
|
{ code: "ai_connection_incompatible" },
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
const parent = path.dirname(directory);
|
|
if (parent === directory) break;
|
|
directory = parent;
|
|
}
|
|
}
|
|
|
|
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_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) {
|
|
try {
|
|
await client`rollback`;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
throw error;
|
|
}
|
|
let released = false;
|
|
return async () => {
|
|
if (released) return;
|
|
released = true;
|
|
try {
|
|
await client`rollback`;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function prepareManagedAiRuntime(
|
|
db: Db,
|
|
input: {
|
|
companyId: string;
|
|
agentId: string;
|
|
responsibleUserId: string | null;
|
|
adapterType: string;
|
|
binding: AiConnectionBinding;
|
|
allowUninstalledPersonal?: boolean;
|
|
allowUninstalledShared?: boolean;
|
|
allowLegacyValidation?: boolean;
|
|
config: Record<string, unknown>;
|
|
},
|
|
) {
|
|
const configuredEnv =
|
|
input.config.env && typeof input.config.env === "object"
|
|
? (input.config.env as Record<string, unknown>)
|
|
: {};
|
|
for (const key of [
|
|
"ANTHROPIC_BASE_URL",
|
|
"OPENAI_BASE_URL",
|
|
"XAI_BASE_URL",
|
|
"CLAUDE_CODE_USE_BEDROCK",
|
|
"CLAUDE_CODE_USE_VERTEX",
|
|
"CLAUDE_CODE_USE_FOUNDRY",
|
|
"PAPERCLIP_OPENCODE_PROVIDERS",
|
|
]) {
|
|
if (configuredEnv[key])
|
|
throw unprocessable(
|
|
"The configured provider routing is incompatible with this AI connection",
|
|
{ code: "ai_connection_incompatible" },
|
|
);
|
|
}
|
|
await assertManagedAiProjectAuth(input.config, input.binding.provider);
|
|
const service = aiConnectionService(db);
|
|
let selection = await service.select({
|
|
...input,
|
|
userId: input.responsibleUserId,
|
|
model: input.config.model,
|
|
runnerProvider: input.config.provider,
|
|
acpxAgent: input.config.acpxAgent,
|
|
});
|
|
const release =
|
|
input.binding.method === "subscription"
|
|
? await acquireCredentialLease(db, selection.grant.id)
|
|
: async () => {};
|
|
let home: string | undefined;
|
|
try {
|
|
const selectedGrantId = selection.grant.id;
|
|
selection = await service.select({
|
|
...input,
|
|
userId: input.responsibleUserId,
|
|
model: input.config.model,
|
|
runnerProvider: input.config.provider,
|
|
acpxAgent: input.config.acpxAgent,
|
|
});
|
|
if (selection.grant.id !== selectedGrantId)
|
|
throw unprocessable(
|
|
"The selected default changed. Retry this execution.",
|
|
);
|
|
const value = await service.credential(selection);
|
|
home = await mkdtemp(
|
|
path.join(
|
|
os.tmpdir(),
|
|
`paperclip-ai-${input.companyId}-${selection.grant.id}-`,
|
|
),
|
|
);
|
|
const providerHome = path.join(home, "provider");
|
|
await mkdir(providerHome, { mode: 0o700 });
|
|
const env: Record<string, unknown> = {
|
|
...stripAiAuthBindings(input.config.env),
|
|
...Object.fromEntries(AI_AUTH_ENV_KEYS.map((key) => [key, ""])),
|
|
HOME: home,
|
|
XDG_CONFIG_HOME: path.join(home, "config"),
|
|
XDG_DATA_HOME: path.join(home, "data"),
|
|
CODEX_HOME: providerHome,
|
|
GROK_HOME: providerHome,
|
|
CLAUDE_CONFIG_DIR: providerHome,
|
|
};
|
|
const capability =
|
|
AI_CONNECTION_CAPABILITIES[input.binding.provider].methods[
|
|
input.binding.method
|
|
]!;
|
|
const authFile = path.join(providerHome, "auth.json");
|
|
if (input.binding.provider === "openai")
|
|
await writeFile(
|
|
path.join(providerHome, "config.toml"),
|
|
'cli_auth_credentials_store = "file"\n',
|
|
{ mode: 0o600 },
|
|
);
|
|
const subscriptionFile =
|
|
input.binding.method === "subscription" &&
|
|
input.binding.provider !== "anthropic";
|
|
if (subscriptionFile) await writeFile(authFile, value, { mode: 0o600 });
|
|
else env[capability.envKey] = value;
|
|
if (
|
|
input.binding.provider === "openai" &&
|
|
input.binding.method === "api_key"
|
|
) {
|
|
env.CODEX_API_KEY = value;
|
|
await writeFile(authFile, JSON.stringify({ OPENAI_API_KEY: value }), {
|
|
mode: 0o600,
|
|
});
|
|
}
|
|
if (input.binding.provider === "openrouter") {
|
|
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
|
provider: { openrouter: { options: { apiKey: value } } },
|
|
});
|
|
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}`;
|
|
return {
|
|
config: {
|
|
...input.config,
|
|
env,
|
|
managedAiConnection: { ...selection.attribution, identity },
|
|
},
|
|
attribution: selection.attribution,
|
|
accountName: selection.connection.name,
|
|
accountOwnerUserId: selection.grant.subjectUserId,
|
|
identity,
|
|
cleanup: async () => {
|
|
try {
|
|
if (subscriptionFile) {
|
|
const refreshed = await readFile(authFile, "utf8");
|
|
if (refreshed !== value)
|
|
await db.transaction(async (tx) => {
|
|
const [grant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.id, selection.grant.id),
|
|
eq(connectionGrants.companyId, input.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
// Reconnect/revocation wins over a process holding an older credential.
|
|
if (
|
|
!grant ||
|
|
grant.status !== "active" ||
|
|
grant.updatedAt.getTime() !==
|
|
selection.grant.updatedAt.getTime()
|
|
)
|
|
return;
|
|
const current = await service.credential({
|
|
...selection,
|
|
grant,
|
|
});
|
|
const destination = path.join(
|
|
providerHome,
|
|
"current-auth.json",
|
|
);
|
|
await writeFile(destination, current, { mode: 0o600 });
|
|
const decision =
|
|
input.binding.provider === "openai"
|
|
? await decideCodexAuthMerge(authFile, destination, {
|
|
errorLabel: "AI account refresh",
|
|
})
|
|
: await decideGrokAuthMerge(authFile, destination, {
|
|
errorLabel: "AI account refresh",
|
|
});
|
|
if (decision !== 10) return;
|
|
const ref = grant.credentialSecretRefs.find(
|
|
(r) => r.configPath === "ai.credential",
|
|
)!;
|
|
await secretService(tx).rotate(
|
|
ref.secretId,
|
|
{ value: refreshed },
|
|
{ userId: grant.subjectUserId },
|
|
);
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set({ updatedAt: new Date() })
|
|
.where(eq(connectionGrants.id, grant.id));
|
|
});
|
|
}
|
|
} finally {
|
|
try {
|
|
if (home) await rm(home, { recursive: true, force: true });
|
|
} finally {
|
|
await release();
|
|
}
|
|
}
|
|
},
|
|
};
|
|
} catch (error) {
|
|
try {
|
|
if (home) await rm(home, { recursive: true, force: true });
|
|
} finally {
|
|
await release();
|
|
}
|
|
throw error;
|
|
}
|
|
}
|