Files
PaperClipAI/server/src/services/ai-connections.ts
T
DottaandClaude Opus 5.5 b2af45f0f1 fix(runner): let OpenAI API-key Codex agents generate images with their existing key (#19654)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - New users often connect a Codex agent with an OpenAI API key during
onboarding.
> - A user asked such an agent for a logo. The agent said that the
built-in image generator was not available and that its fallback needed
a separate API key. It then drew an SVG by hand.
> - #19516 removed `--disable image_generation`. That change is correct,
but it does not reach this path. The pinned Codex 0.160.0 shows its
`image_gen` tool only when Codex signs in with ChatGPT. The check is
`image_generation_available` in `codex-rs/core/src/tools/spec_plan.rs`.
An API key never gets the tool.
> - The runner keeps the provider key out of the agent's shell on
purpose, so the skill's `scripts/image_gen.py` fallback cannot run.
> - This pull request adds a Paperclip `generate_image` tool. The server
makes the image with the agent's existing OpenAI key. The key never goes
to the agent process or the shell.
> - The benefit is that API-key Codex agents can make images with no
second key. The spend goes to the agent's budget and the budget can stop
it.

## Linked Issues or Issue Description

Refs #19516

**What happened?**
An agent connected through onboarding with an OpenAI API key (Codex
harness, `gpt-5.6-sol`) could not generate images. The agent said: "The
image skill's built-in generator isn't available in this run, and its
fallback would require a separate API key." It then drew an SVG instead.

**Steps to reproduce**
1. Create a company with a Codex agent that uses an OpenAI API key.
2. Ask the agent for a raster image, for example a logo PNG.
3. The agent reports that image generation is not available. It does not
make the image.

**Expected behavior**
The agent makes the image with the OpenAI key that already powers it.

**Root cause**
The agent ran on the native runner's Codex app-server path, with
`auth.json = {"OPENAI_API_KEY": ...}`. Codex 0.160.0 removes
`image_gen.imagegen` from the model's tools unless
`current_auth_uses_codex_backend()` is true. That function is true only
for a ChatGPT sign-in.

I proved this with the real pinned binary. I gave it the runner's
isolated launch arguments and the API-key auth file, and pointed it at a
local Responses endpoint. The first model request offers only `exec`,
`wait`, `request_user_input` and the collaboration tools. #19516's test
checked only `codex features list`, so it could not see this gate.

## What Changed

- **New `server/src/services/native-runtime/generate-image-tool.ts`:**
  - The tool schema and definition.
- `imageGenerationBindingForRun`. It applies only to native Codex runs
on a direct OpenAI `api_key` connection with no router selection.
- The OpenAI Images API call (`gpt-image-2`, `b64_json`, PNG check, 180
s timeout). The reply is read as a stream with a byte cap before it is
parsed. A decoded image larger than 20 MiB is refused.
- A reviewed rate card for `gpt-image-2`: text input $5, image input $8
and image output $30 per 1M tokens, from
developers.openai.com/api/docs/pricing, retrieved 2026-11-06.
  - A create-only, symlink-confined local write.
- **`paperclip-runner-tool-authority.ts`:**
  - Adds the `imageGeneration` binding.
- Advertises the tool only for standard mode with a workspace. A remote
target must also have an installer. Dot, Muse, Grok and native review do
not get the tool.
- Adds the `#generateImage` handler. Before any spend, it checks again:
the run and task, budget authority, `issue:mutate`, case handoff, and
the connection.
- Stores a claim-before-spend per-call receipt in
`resultJson.imageToolReceipts`. A retry or recovery replays the receipt
and does not pay again.
- Saves a `charged` receipt (usage, price, request ID and charge time)
as soon as OpenAI returns. A failed save, failed accounting write or
crash can then still settle the spend. A retry or recovery records the
cost once and never buys again.
- Records one `cost_events` row through `createCostEventInTransaction`.
The row is `metered_api` with `costStatus: "estimated"`, and the budget
is evaluated. The row is recorded even if saving fails.
- Checks the task's project budget, as well as the company and agent
budgets, before spending.
- Writes content-free `runner.image_generate` activity entries: started,
completed, failed and unconfirmed.
- **`ai-connections.ts`:** adds `selectImageGeneration`. It checks again
that the connection is an active, healthy OpenAI `api_key` connection
with no custom routing. It also checks the responsible user's
membership, the grant audience and the agent or company installation.
These are the same rules that runtime selection uses.
- **`native-session-executor.ts`:** gets the binding from the run's
admitted `contextSnapshot.aiConnection`. The value comes only from the
server, never from tool input.
- **`native-session-resume.ts`:** the native tool contract changes from
`v18` to `v19` and now lists `generate_image`. A persisted provider
thread cannot add tools when it resumes, so older threads must rotate.
- **`doc/runner-api-tools.md`:** a new section about image generation
for OpenAI API-key Codex agents.
- **#19516: kept, not reverted.** Its changes have no effect on the
API-key path. They are still correct for Codex agents that sign in with
ChatGPT, because `image_gen` works there and saves to
`CODEX_HOME/generated_images`. Those agents need the read grant that
#19516 added. On the API-key path, the new tool replaces the built-in
tool. The tool's description tells the agent to use it instead of
`image_gen` or `scripts/image_gen.py`.

## Verification

**Red on `master` (e482f0f632).** Behavioural red with the first version
of the new test:

```
 FAIL  src/services/native-runtime/generate-image-tool.test.ts > generate_image for OpenAI API-key Codex agents (N-12) > lets an API-key Codex agent make an image with its existing OpenAI key, saved in its workspace and billed
AssertionError: expected [ 'connections_search', …(51) ] to include 'generate_image'
 ❯ src/services/native-runtime/generate-image-tool.test.ts:78:60
      Tests  1 failed | 3 passed (4)
```

The final test file on master's source also fails. The reason is that
the module does not exist: `Cannot find module
'./generate-image-tool.js'`.

**Real pinned Codex binary.** The new test is
`packages/paperclip-runner/src/drivers/codex/codex-api-key-image-tools.test.ts`.
It uses the runner's isolated launch, the API-key `auth.json` and a
local Responses endpoint. It shows that the model gets no `image_gen`,
`imagegen` or `image_generation` tool, and that a server-advertised
`generate_image` tool does reach the model:

```
 Test Files  1 passed (1)
      Tests  1 passed (1)
```

**Green after the fix:**
`server/src/services/native-runtime/generate-image-tool.test.ts` uses
embedded PostgreSQL and a stubbed OpenAI fetch.

```
 Test Files  1 passed (1)
      Tests  11 passed (11)
```

The 11 tests cover these behaviours:

- **Main path.** The tool is advertised. The request goes to
`api.openai.com/v1/images/generations` with the existing key. The PNG is
written to the workspace. The key does not appear in the result or in
activity. One `cost_events` row is written: 50 text tokens and 1000
output tokens, which is $0.03025, estimated, from the rate card. A retry
and `recover()` replay the receipt with no second fetch and no second
cost row.
- **No binding:** the tool is not offered and `execute` refuses.
- **Budget hard-stop.** The stop pauses the agent, so the call is
refused before any fetch and no file is written.
- **Bad grant:** the call is refused before any fetch.
- **Project budget stop.** The task's project is paused by its budget,
so the call is refused before any fetch.
- **Charge settlement.** A crash left a `charged` receipt. The retry and
`recover()` record the cost once, report the image path and do not buy
another image.
- **Oversized reply.** The read stops before the body is parsed.
- **Save failure.** The spend is still recorded. A same-call retry does
not pay again and reports the failure.
- **Binding gate.** Only Codex on a direct OpenAI API key gets the
binding. A subscription, ACPX, OpenRouter or a routed connection does
not.
- **Rate card math.**
- **Provider errors.** The error text does not contain the key, and
non-PNG data is rejected.

**Neighbouring suites:**

- `src/drivers/codex` in the runner package: 24 files, 350 tests passed.
- Server: `paperclip-runner-tool-authority.test.ts`,
`native-session-resume.test.ts`, `fast-responses.test.ts`,
`ai-connection-session.test.ts` and the new suite. 157 of 158 passed.
- The one failure is `native-session-resume.test.ts › archives a damaged
prior epoch … with real runnerd` (ENOENT on `authority-epochs`). It
fails the same way on unmodified `master` in this local environment, so
this change did not cause it.
- `tsc --noEmit -p server/tsconfig.json`: clean.
- `pnpm verify` exits 2 (broad). Required CI supplies the broad
coverage.

## Risks

- **New security surface: this needs maintainer sign-off.** The server
now sends the agent's OpenAI API key to `api.openai.com` on the agent's
request. The tool is limited in these ways:
  - The key stays in the server.
- Each call checks run, task, budget, mutation and connection authority
again.
- The destination name comes from the server and must match
`^[a-z0-9-]+-[0-9a-f]{8}\.png$`. The write is create-only and confined
to the workspace root. On macOS it uses `O_NOFOLLOW_ANY`. On Linux it
uses a `/proc` descriptor walk.
- **Spend.** Each call costs money. The tool description tells the agent
to make only what the task needs. The cost is an estimate from a rate
card (`costStatus: "estimated"`), not an invoice. The rate card applies
the image-output rate to all output tokens, so the estimate errs on the
high side.
- **Checkpoint rotation.** The tool-contract bump from v18 to v19 makes
existing native provider threads start fresh once at their next turn.
This is what the contract requires when the tool surface changes.
- **Pricing drift.** If OpenAI changes `gpt-image-2` prices, the rate
card must be updated.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude
Code with tool use (shell, file edits, sub-agents) and extended
reasoning.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 07:48:39 -06:00

1274 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { notifyLoginSessionChanged } from "./login-cleanup-notifications.js";
import type { ConnectionManagementContext } from "./connection-management.js";
import { WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { withAccountHomeSecretMutationLock } from "@paperclipai/adapter-codex-local/server";
import { syncConnectionCredentialBindings } from "./connection-credential-bindings.js";
import { createHash, randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import {
type Db,
authUsers,
adapterAuthSessions,
aiConnectionDefaults,
aiProviderDefaults,
agents,
heartbeatRuns,
companyMemberships,
companySecrets,
userSecretDefinitions,
connectionGrants,
connectionGrantMembers,
toolApplications,
toolConnections,
toolConnectionInstalls,
} from "@paperclipai/db";
import {
AI_CONNECTION_CAPABILITIES,
decisionProviderForConnection,
aiConnectionCatalogSlug,
getAppStoreDefinition,
aiConnectionMetadataSchema,
aiSubscriptionNeedsIsolatedLogin,
isAiConnectionCompatible,
supportsAiConnectionUsage,
type AiConnectionBinding,
type AiConnectionAttribution,
type AiConnectionMetadata,
type AiManagedConnectionSummary,
type AiConnectionAccountIdentity,
type AiConnectionUsage,
type CreateAiConnection,
type AiConnectionLoginIntent,
} from "@paperclipai/shared";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { aiConnectionConfigurationFailure, aiConnectionCredentialNotSharedFailure } from "./ai-connection-configuration-failure.js";
import { quotaCredentialRecovery, quotaCredentialHash } from "./quota-credential-recovery.js";
import { prepareAntigravityCredentialRecovery } from "./native-runtime/antigravity-runner-credentials.js";
import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
import { secretService } from "./secrets.js";
import { probeAiConnectionUsage } from "./ai-connection-usage.js";
import { probeAiAccountIdentity } from "./ai-account-identity.js";
/** Same human audience displayed by the existing Connections identity controls. */
function canUseCredential(
grant: { kind: string; subjectUserId: string | null },
userId: string | null,
audience: { subjectType: string; subjectId: string }[],
) {
if (!userId) return false;
if (grant.kind === "user") return grant.subjectUserId === userId;
return grant.kind === "organization" && (
audience.length === 0 || audience.some((member) => member.subjectType === "user" && member.subjectId === userId)
);
}
export function aiConnectionService(db: Db) {
const secrets = secretService(db);
async function membership(companyId: string, userId: string | null) {
if (!userId) return false;
return Boolean(
(
await db
.select({ id: companyMemberships.id })
.from(companyMemberships)
.where(
and(
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId),
eq(companyMemberships.status, "active"),
),
)
.limit(1)
)[0],
);
}
async function rows(companyId: string) {
return db
.select({ connection: toolConnections, grant: connectionGrants })
.from(toolConnections)
.innerJoin(
connectionGrants,
and(
eq(connectionGrants.companyId, toolConnections.companyId),
eq(connectionGrants.connectionId, toolConnections.id),
),
)
.where(
and(
eq(toolConnections.companyId, companyId),
eq(toolConnections.connectionPurpose, "ai"),
),
);
}
async function list(
companyId: string,
userId: string,
_agentId?: string,
): Promise<AiManagedConnectionSummary[]> {
const [accounts, defaults, members, owners] =
await Promise.all([
rows(companyId),
db
.select()
.from(aiProviderDefaults)
.where(
and(
eq(aiProviderDefaults.companyId, companyId),
eq(aiProviderDefaults.userId, userId),
),
),
db
.select()
.from(connectionGrantMembers)
.where(eq(connectionGrantMembers.companyId, companyId)),
db
.select({ id: authUsers.id, name: authUsers.name })
.from(authUsers)
.innerJoin(
companyMemberships,
and(
eq(companyMemberships.principalId, authUsers.id),
eq(companyMemberships.companyId, companyId),
eq(companyMemberships.principalType, "user"),
),
),
]);
return accounts.flatMap(({ connection, grant }) => {
const metadata = aiConnectionMetadataSchema.safeParse(
connection.config.ai,
);
if (!metadata.success) return [];
const needsReconnect = aiSubscriptionNeedsIsolatedLogin(connection.config);
if (!canUseCredential(grant, userId, members.filter((m) => m.grantId === grant.id)))
return [];
return [
{
id: connection.id,
grantId: grant.id,
companyId,
...metadata.data,
usageProbeSupported: supportsAiConnectionUsage(metadata.data.provider, metadata.data.method),
name: connection.name,
accountLabel: grant.providerTenant?.name,
...(needsReconnect ? { unavailableReason: "Reconnect with a separate sign-in to protect your existing terminal login." } : {}),
ownership:
grant.kind === "user" ? ("personal" as const) : ("shared" as const),
ownerUserId: grant.subjectUserId ?? undefined,
ownerName:
owners.find((owner) => owner.id === grant.subjectUserId)?.name ??
(grant.subjectUserId === userId ? "You" : "Account owner"),
isDefault: defaults.some((d) => d.grantId === grant.id),
status:
grant.status === "revoked"
? ("revoked" as const)
: grant.status === "expired"
? ("expired" as const)
: grant.status !== "active" ||
!connection.enabled ||
connection.status !== "active" ||
connection.healthStatus !== "ok" || needsReconnect
? ("needs_attention" as const)
: ("connected" as const),
},
];
}).sort((a, b) => a.id.localeCompare(b.id) || a.grantId.localeCompare(b.grantId));
}
async function setDefault(
companyId: string,
userId: string,
grantId: string,
) {
return db.transaction(async (tx) => {
const [row] = await tx
.select({ connection: toolConnections, grant: connectionGrants })
.from(connectionGrants)
.innerJoin(
toolConnections,
eq(toolConnections.id, connectionGrants.connectionId),
)
.where(
and(
eq(connectionGrants.companyId, companyId),
eq(connectionGrants.id, grantId),
eq(connectionGrants.subjectUserId, userId),
eq(connectionGrants.kind, "user"),
),
)
.for("update");
if (!row)
throw forbidden("Only the owner can choose their personal default");
if (
row.connection.connectionPurpose !== "ai" ||
row.grant.status !== "active" ||
!row.connection.enabled ||
row.connection.healthStatus !== "ok"
)
throw unprocessable(
"Reconnect this account before making it your default",
);
const metadata = aiConnectionMetadataSchema.parse(row.connection.config.ai);
if (metadata.routing) throw unprocessable("Custom providers use an explicit connection selection on the agent.");
// Keep old servers' method preferences intact during an additive rollout.
await tx.insert(aiConnectionDefaults)
.values({ companyId, userId, ...metadata, grantId })
.onConflictDoUpdate({
target: [aiConnectionDefaults.companyId, aiConnectionDefaults.userId, aiConnectionDefaults.provider, aiConnectionDefaults.method],
set: { grantId, updatedAt: new Date() },
});
await tx.insert(aiProviderDefaults)
.values({ companyId, userId, provider: metadata.provider, grantId })
.onConflictDoUpdate({
target: [aiProviderDefaults.companyId, aiProviderDefaults.userId, aiProviderDefaults.provider],
set: { grantId, updatedAt: new Date() },
});
});
}
async function readableAccount(companyId: string, userId: string, connectionId: string, grantId?: string) {
if (!(await membership(companyId, userId))) throw forbidden("An active company member is required");
const candidates = (await rows(companyId)).filter((row) => row.connection.id === connectionId && (!grantId || row.grant.id === grantId));
const audience = await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId));
const visible = candidates.filter(({ grant }) => canUseCredential(grant, userId, audience.filter((member) => member.grantId === grant.id)));
if (!visible.length) throw notFound("AI connection not found");
if (visible.length > 1) throw unprocessable("Choose a credential grant for this account check");
const row = visible[0]!;
const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai);
if (!metadata.success) throw notFound("AI connection not found");
return { row, metadata: metadata.data };
}
/** On-demand account observation for either runner; never probes ambient CLI auth. */
async function probeUsage(companyId: string, userId: string, connectionId: string, grantId?: string): Promise<AiConnectionUsage> {
const { row, metadata } = await readableAccount(companyId, userId, connectionId, grantId);
const identity = { connectionId: row.connection.id, grantId: row.grant.id, ...metadata };
const empty = { ...identity, checkedAt: new Date().toISOString(), source: null, planType: null, limits: [], overage: null };
if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" ||
row.connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(row.connection.config)) {
return { ...empty, status: "unavailable", errorCode: "connection_unavailable", message: "Reconnect or enable this AI connection before checking usage." };
}
if (!supportsAiConnectionUsage(metadata.provider, metadata.method)) {
return { ...identity, ...await probeAiConnectionUsage(metadata, "") };
}
const value = await credential(row);
return { ...identity, ...await probeAiConnectionUsage(metadata, value) };
}
async function accountIdentity(companyId: string, userId: string, connectionId: string, grantId?: string,
audit?: { actorType: "user" | "agent" | "system"; actorId: string; heartbeatRunId?: string | null }): Promise<AiConnectionAccountIdentity> {
const { row, metadata } = await readableAccount(companyId, userId, connectionId, grantId);
const identity = { connectionId: row.connection.id, grantId: row.grant.id };
if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" ||
row.connection.healthStatus !== "ok" || aiSubscriptionNeedsIsolatedLogin(row.connection.config)) {
return { ...identity, status: "unavailable", checkedAt: new Date().toISOString(), accountLabel: null, organizationLabel: null,
errorCode: "connection_unavailable", message: "Reconnect or enable this connection to check its account details." };
}
// Unsupported methods must not open their credentials just to report this limit.
const value = metadata.provider === "anthropic" && metadata.method === "subscription" && !metadata.routing
? await credential(row, 0, { actorType: "user", actorId: userId, ...audit, responsibleUserId: userId }) : "";
return { ...identity, ...await probeAiAccountIdentity(metadata, value) };
}
async function select(input: {
companyId: string;
userId: string | null;
agentId: string;
adapterType: string;
model?: unknown;
runnerProvider?: unknown;
acpxAgent?: unknown;
allowUninstalledPersonal?: boolean;
allowUninstalledShared?: boolean;
allowLegacyValidation?: boolean;
binding: AiConnectionBinding;
}) {
const { companyId, userId, agentId, binding } = input;
// The grant-kind checks below cover only these three modes. Callers parse
// the binding first, but an unknown mode must never skip those checks.
if (binding.mode !== "responsible_user" && binding.mode !== "shared" && binding.mode !== "delegated")
throw aiConnectionConfigurationFailure("ai_connection_incompatible", "Select a supported AI connection mode");
if (
!isAiConnectionCompatible(
binding,
input.adapterType,
input.model,
input.runnerProvider,
input.acpxAgent,
)
)
throw aiConnectionConfigurationFailure("ai_connection_incompatible", "Select an AI connection compatible with this harness and model");
if (binding.mode === "responsible_user" && !userId)
throw aiConnectionConfigurationFailure("ai_connection_responsible_user_missing", "This run needs a responsible user to select an AI connection");
if (userId && !(await membership(companyId, userId)))
throw forbidden("The responsible user is not an active company member");
const defaultRow =
binding.mode === "responsible_user"
? (
await db
.select()
.from(aiProviderDefaults)
.where(
and(
eq(aiProviderDefaults.companyId, companyId),
eq(aiProviderDefaults.userId, userId!),
eq(aiProviderDefaults.provider, binding.provider),
),
)
.limit(1)
)[0]
: null;
const grantId =
binding.mode === "responsible_user"
? defaultRow?.grantId
: binding.grantId;
if (!grantId)
throw aiConnectionConfigurationFailure("ai_connection_default_missing", "Connect an account and choose your personal default");
const row = (await rows(companyId)).find(
(r) =>
r.grant.id === grantId &&
(binding.mode === "responsible_user" ||
r.connection.id === binding.connectionId),
);
if (!row)
throw aiConnectionConfigurationFailure("ai_connection_missing", "The selected AI connection is unavailable");
const { connection, grant } = row;
const metadata = aiConnectionMetadataSchema.safeParse(connection.config.ai);
if (
!metadata.success ||
metadata.data.provider !== binding.provider ||
(binding.mode !== "responsible_user" && metadata.data.method !== binding.method) ||
!isAiConnectionCompatible(metadata.data, input.adapterType, input.model, input.runnerProvider, input.acpxAgent)
)
throw aiConnectionConfigurationFailure("ai_connection_incompatible", "The selected AI connection is incompatible");
if (aiSubscriptionNeedsIsolatedLogin(connection.config))
throw aiConnectionConfigurationFailure("ai_connection_unavailable", "Reconnect this subscription with a separate sign-in to protect your existing terminal login.", { connectionId: connection.id });
if (
grant.status !== "active" ||
!connection.enabled ||
connection.status !== "active" ||
(connection.healthStatus !== "ok" &&
!(
input.allowLegacyValidation &&
connection.config.aiLegacyAdoption === true
))
)
throw aiConnectionConfigurationFailure("ai_connection_unavailable", "Reconnect or validate the selected AI account", { connectionId: connection.id });
if (
grant.kind === "user" &&
!(await membership(companyId, grant.subjectUserId))
)
throw forbidden(
"The account owner is no longer an active company member",
);
if (
binding.mode === "responsible_user" &&
(grant.kind !== "user" || grant.subjectUserId !== userId)
)
throw forbidden("The default must belong to the responsible user");
if (binding.mode === "shared" && grant.kind !== "organization")
throw forbidden("Select a company-shared account");
if (binding.mode === "delegated" && grant.kind !== "user")
throw forbidden("Select a personal account");
const audience = await db
.select()
.from(connectionGrantMembers)
.where(and(
eq(connectionGrantMembers.companyId, companyId),
eq(connectionGrantMembers.grantId, grant.id),
));
// The existing human-access permission is authoritative for every binding,
// including old explicit personal selections. Agent delegation cannot bypass it.
if (!canUseCredential(grant, userId, audience)) {
// Membership was checked above. A known user's sharing choice is an
// operator action; absent identity must remain an unclassified denial.
if (userId) throw aiConnectionCredentialNotSharedFailure({ connectionName: connection.name, grantId: grant.id });
throw forbidden("This credential is not shared with the responsible user");
}
const installs = await db
.select()
.from(toolConnectionInstalls)
.where(
and(
eq(toolConnectionInstalls.companyId, companyId),
eq(toolConnectionInstalls.connectionId, connection.id),
or(
and(
eq(toolConnectionInstalls.targetType, "company"),
eq(toolConnectionInstalls.targetId, companyId),
),
and(
eq(toolConnectionInstalls.targetType, "agent"),
eq(toolConnectionInstalls.targetId, agentId),
),
),
),
);
if (
!installs.length &&
!(
(input.allowUninstalledPersonal &&
grant.kind === "user" && grant.subjectUserId === userId) ||
(input.allowUninstalledShared && binding.mode === "shared" && grant.kind === "organization")
)
)
throw forbidden("This connection is not permitted for this agent");
return {
...row,
attribution: {
connectionId: connection.id,
grantId: grant.id,
provider: binding.provider,
method: metadata.data.method,
mode: binding.mode,
responsibleUserId: userId,
} satisfies AiConnectionAttribution,
};
}
/** Internal decision use has its own capability check, never a pretend CLI harness. */
async function selectDecision(input: {
companyId: string; connectionId: string; grantId: string;
userId: string | null; agentId?: string | null; sponsoredBackground?: boolean;
}) {
const [row] = await db.select({ connection: toolConnections, grant: connectionGrants })
.from(toolConnections).innerJoin(connectionGrants, and(
eq(connectionGrants.connectionId, toolConnections.id), eq(connectionGrants.companyId, toolConnections.companyId),
)).where(and(eq(toolConnections.companyId, input.companyId), eq(toolConnections.id, input.connectionId),
eq(connectionGrants.id, input.grantId), eq(toolConnections.connectionPurpose, "ai"))).limit(1);
if (!row || row.grant.kind !== "organization") throw unprocessable("Shared AI connection unavailable", { code: "connection_unavailable" });
const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai);
const provider = metadata.success ? decisionProviderForConnection(metadata.data) : null;
if (!provider) throw unprocessable("Connection does not support decisions", { code: "incompatible_connection" });
if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" || row.connection.healthStatus !== "ok")
throw unprocessable("Reconnect or enable this connection", { code: "connection_unavailable" });
// Only the decision service may sponsor background use; ordinary AI selection never bypasses an audience.
if (!input.sponsoredBackground) {
if (!(await membership(input.companyId, input.userId))) throw forbidden("Active responsible user required");
const audience = await db.select().from(connectionGrantMembers).where(and(
eq(connectionGrantMembers.companyId, input.companyId), eq(connectionGrantMembers.grantId, input.grantId),
));
if (!canUseCredential(row.grant, input.userId, audience)) throw forbidden("Connection is not shared with this user");
} else if (input.userId || input.agentId) throw forbidden("Background sponsorship cannot replace caller permissions");
if (input.agentId) {
const [install] = await db.select({ id: toolConnectionInstalls.id }).from(toolConnectionInstalls).where(and(
eq(toolConnectionInstalls.companyId, input.companyId), eq(toolConnectionInstalls.connectionId, input.connectionId),
or(and(eq(toolConnectionInstalls.targetType, "company"), eq(toolConnectionInstalls.targetId, input.companyId)),
and(eq(toolConnectionInstalls.targetType, "agent"), eq(toolConnectionInstalls.targetId, input.agentId))),
)).limit(1);
if (!install) throw forbidden("Connection is not permitted for this agent");
}
return { ...row, provider };
}
/**
* A native run's image tool reuses the exact AI connection the run was
* admitted with. Recheck the same permissions as runtime selection on every
* call: an OpenAI API key sent directly to OpenAI, active and healthy, shared
* with the responsible user and installed for this agent.
*/
async function selectImageGeneration(input: {
companyId: string; connectionId: string; grantId: string;
userId: string | null; agentId: string;
}) {
const [row] = await db.select({ connection: toolConnections, grant: connectionGrants })
.from(toolConnections).innerJoin(connectionGrants, and(
eq(connectionGrants.connectionId, toolConnections.id), eq(connectionGrants.companyId, toolConnections.companyId),
)).where(and(eq(toolConnections.companyId, input.companyId), eq(toolConnections.id, input.connectionId),
eq(connectionGrants.id, input.grantId), eq(toolConnections.connectionPurpose, "ai"))).limit(1);
if (!row) throw unprocessable("AI connection unavailable", { code: "connection_unavailable" });
const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai);
if (!metadata.success || metadata.data.provider !== "openai" || metadata.data.method !== "api_key" || metadata.data.routing)
throw unprocessable("Image generation needs an OpenAI API-key connection", { code: "incompatible_connection" });
if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" || row.connection.healthStatus !== "ok")
throw unprocessable("Reconnect or enable this connection", { code: "connection_unavailable" });
if (!input.userId || !(await membership(input.companyId, input.userId))) throw forbidden("Active responsible user required");
if (row.grant.kind === "user" && !(await membership(input.companyId, row.grant.subjectUserId)))
throw forbidden("The account owner is no longer an active company member");
const audience = await db.select().from(connectionGrantMembers).where(and(
eq(connectionGrantMembers.companyId, input.companyId), eq(connectionGrantMembers.grantId, input.grantId),
));
if (!canUseCredential(row.grant, input.userId, audience)) throw forbidden("Connection is not shared with this user");
const [install] = await db.select({ id: toolConnectionInstalls.id }).from(toolConnectionInstalls).where(and(
eq(toolConnectionInstalls.companyId, input.companyId), eq(toolConnectionInstalls.connectionId, input.connectionId),
or(and(eq(toolConnectionInstalls.targetType, "company"), eq(toolConnectionInstalls.targetId, input.companyId)),
and(eq(toolConnectionInstalls.targetType, "agent"), eq(toolConnectionInstalls.targetId, input.agentId))),
)).limit(1);
if (!install) throw forbidden("Connection is not permitted for this agent");
return row;
}
/** Internal fast-response use has its own capability check, never a pretend CLI harness. */
async function selectFastResponse(input: {
companyId: string; connectionId: string; grantId: string;
userId: string | null; agentId?: string | null; sponsoredBackground?: boolean;
}) {
const [row] = await db.select({ connection: toolConnections, grant: connectionGrants })
.from(toolConnections).innerJoin(connectionGrants, and(
eq(connectionGrants.connectionId, toolConnections.id), eq(connectionGrants.companyId, toolConnections.companyId),
)).where(and(eq(toolConnections.companyId, input.companyId), eq(toolConnections.id, input.connectionId),
eq(connectionGrants.id, input.grantId), eq(toolConnections.connectionPurpose, "ai"))).limit(1);
if (!row || row.grant.kind !== "organization") throw unprocessable("Shared AI connection unavailable", { code: "connection_unavailable" });
const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai);
const provider = metadata.success && metadata.data.method === "api_key" ? metadata.data.provider : null;
if (!provider) throw unprocessable("Connection does not support fast responses", { code: "incompatible_connection" });
if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" || row.connection.healthStatus !== "ok")
throw unprocessable("Reconnect or enable this connection", { code: "connection_unavailable" });
// Only internal fast responses may sponsor background use; ordinary AI selection never bypasses an audience.
if (!input.sponsoredBackground) {
if (!(await membership(input.companyId, input.userId))) throw forbidden("Active responsible user required");
const audience = await db.select().from(connectionGrantMembers).where(and(
eq(connectionGrantMembers.companyId, input.companyId), eq(connectionGrantMembers.grantId, input.grantId),
));
if (!canUseCredential(row.grant, input.userId, audience)) throw forbidden("Connection is not shared with this user");
} else if (input.userId) throw forbidden("Background sponsorship cannot replace caller permissions");
if (input.agentId) {
const [install] = await db.select({ id: toolConnectionInstalls.id }).from(toolConnectionInstalls).where(and(
eq(toolConnectionInstalls.companyId, input.companyId), eq(toolConnectionInstalls.connectionId, input.connectionId),
or(and(eq(toolConnectionInstalls.targetType, "company"), eq(toolConnectionInstalls.targetId, input.companyId)),
and(eq(toolConnectionInstalls.targetType, "agent"), eq(toolConnectionInstalls.targetId, input.agentId))),
)).limit(1);
if (!install) throw forbidden("Connection is not permitted for this agent");
}
return { ...row, provider };
}
async function selectPromptInjectionPrevention(input: {
companyId: string; connectionId: string; grantId: string;
userId: string | null; agentId?: string | null; sponsoredBackground?: boolean;
}) {
const [row] = await db.select({ connection: toolConnections, grant: connectionGrants })
.from(toolConnections).innerJoin(connectionGrants, and(
eq(connectionGrants.connectionId, toolConnections.id), eq(connectionGrants.companyId, toolConnections.companyId),
)).where(and(eq(toolConnections.companyId, input.companyId), eq(toolConnections.id, input.connectionId),
eq(connectionGrants.id, input.grantId), eq(toolConnections.connectionPurpose, "ai"))).limit(1);
if (!row || row.grant.kind !== "organization") throw unprocessable("Shared AI connection unavailable", { code: "connection_unavailable" });
const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai);
const provider = metadata.success && decisionProviderForConnection(metadata.data) === "openrouter" ? "openrouter" as const : null;
if (!provider) throw unprocessable("Connection does not support prompt injection checks", { code: "incompatible_connection" });
if (row.grant.status !== "active" || !row.connection.enabled || row.connection.status !== "active" || row.connection.healthStatus !== "ok")
throw unprocessable("Reconnect or enable this connection", { code: "connection_unavailable" });
// Only internal prompt injection checks may sponsor background use; ordinary AI selection never bypasses an audience.
if (!input.sponsoredBackground) {
if (!(await membership(input.companyId, input.userId))) throw forbidden("Active responsible user required");
const audience = await db.select().from(connectionGrantMembers).where(and(
eq(connectionGrantMembers.companyId, input.companyId), eq(connectionGrantMembers.grantId, input.grantId),
));
if (!canUseCredential(row.grant, input.userId, audience)) throw forbidden("Connection is not shared with this user");
} else if (input.userId) throw forbidden("Background sponsorship cannot replace caller permissions");
if (input.agentId) {
const [install] = await db.select({ id: toolConnectionInstalls.id }).from(toolConnectionInstalls).where(and(
eq(toolConnectionInstalls.companyId, input.companyId), eq(toolConnectionInstalls.connectionId, input.connectionId),
or(and(eq(toolConnectionInstalls.targetType, "company"), eq(toolConnectionInstalls.targetId, input.companyId)),
and(eq(toolConnectionInstalls.targetType, "agent"), eq(toolConnectionInstalls.targetId, input.agentId))),
)).limit(1);
if (!install) throw forbidden("Connection is not permitted for this agent");
}
return { ...row, provider };
}
async function credential(row: Pick<Awaited<ReturnType<typeof select>>, "connection" | "grant">, retry = 0, audit?: { responsibleUserId: string | null; actorType: "user" | "agent" | "system"; actorId: string; issueId?: string | null; heartbeatRunId?: string | null }): Promise<string> {
const metadata = aiConnectionMetadataSchema.parse(row.connection.config.ai);
if (metadata.routing?.auth === "none") return "";
const ref = row.grant.credentialSecretRefs.find(
(r) => r.configPath === "ai.credential",
);
if (!ref)
throw unprocessable("Reconnect this AI account", {
code: "ai_connection_credential_missing",
});
const [secret] = await db
.select()
.from(companySecrets)
.where(
and(
eq(companySecrets.id, ref.secretId),
eq(companySecrets.companyId, row.connection.companyId),
),
);
if (!secret)
throw unprocessable("Reconnect this AI account", {
code: "ai_connection_credential_missing",
});
if (row.grant.kind === "user" && secret.scope !== "user")
throw forbidden("Credential ownership mismatch");
if (row.grant.kind === "organization" && secret.scope !== "company") throw forbidden("Credential ownership mismatch");
const context = {
consumerType: "tool_connection" as const,
consumerId: row.connection.id,
configPath: ref.configPath,
responsibleUserId: row.grant.subjectUserId,
actorType: "system" as const,
...audit,
};
let value: string;
if (secret.scope === "user") {
if (
secret.ownerUserId !== row.grant.subjectUserId ||
!secret.userSecretDefinitionId
)
throw forbidden("Credential ownership mismatch");
const result = await secrets.resolveUserSecretValue(
row.connection.companyId,
{
definitionId: secret.userSecretDefinitionId,
responsibleUserId: secret.ownerUserId,
required: true,
version: "latest",
},
context,
);
if (!result) throw unprocessable("Reconnect this AI account");
value = result.value;
} else {
value = await secrets.resolveSecretValue(row.connection.companyId, secret.id, "latest", context);
}
// Resolution records lastResolvedAt and can wait behind a rotating writer.
// Re-read after that wait instead of handing a run the pre-rotation token.
const [latest] = await db.select().from(companySecrets).where(and(
eq(companySecrets.id, secret.id), eq(companySecrets.companyId, row.connection.companyId),
));
if (!latest || latest.status !== "active") throw unprocessable("Reconnect this AI account");
if (latest.latestVersion !== secret.latestVersion) {
if (retry >= 2) throw unprocessable("AI credentials are changing. Retry this execution.", { code: "ai_connection_busy" });
return credential(row, retry + 1, audit);
}
return value;
}
async function save(
companyId: string,
userId: string,
input: CreateAiConnection | AiConnectionLoginIntent,
verifiedCredential: string,
loginSession?: { id: string; complete?: (tx: Db) => Promise<void> },
attemptStartedAt = new Date(),
managementContext?: ConnectionManagementContext,
verifiedAccountLabel?: string,
) {
await managementContext?.reauthorize();
const routing = "routing" in input ? input.routing : undefined;
if (!(await membership(companyId, userId)))
throw forbidden("An active company member must own this connection");
const reconnect = input.connectionId
? (await rows(companyId)).find(
(r) => r.connection.id === input.connectionId,
)
: undefined;
if (input.connectionId && !reconnect)
throw notFound("AI connection not found");
if (
reconnect &&
(reconnect.grant.createdByUserId !== userId ||
(reconnect.grant.kind === "user" &&
reconnect.grant.subjectUserId !== userId))
)
throw forbidden("Only the account owner can reconnect it");
if (
reconnect &&
(reconnect.connection.config.ai as AiConnectionMetadata).provider !==
input.provider
)
throw unprocessable("Reconnect cannot change providers");
if (reconnect && !isDeepStrictEqual((reconnect.connection.config.ai as AiConnectionMetadata).routing, routing))
throw unprocessable("Reconnect must retain this connection’s routing. Create another connection to change its destination.");
if (
reconnect &&
((reconnect.connection.config.ai as AiConnectionMetadata).method !==
input.method ||
(reconnect.grant.kind === "user") !== (input.ownership === "personal"))
)
throw unprocessable(
"Reconnect cannot change the sign-in method or ownership",
);
const id = reconnect?.connection.id ?? randomUUID();
const grantId = reconnect?.grant.id ?? randomUUID();
const persist = () => withAccountHomeSecretMutationLock(undefined, companyId, () => db.transaction(async (tx) => {
await managementContext?.reauthorize();
const secrets = secretService(tx);
if (loginSession) {
const [session] = await tx
.select()
.from(adapterAuthSessions)
.where(
and(
eq(adapterAuthSessions.id, loginSession.id),
eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.startedByUserId, userId),
),
)
.for("update");
if (!session) throw forbidden("Login session ownership mismatch");
attemptStartedAt = session.createdAt;
if (session.connectionId && session.connectionGrantId)
return {
connectionId: session.connectionId,
grantId: session.connectionGrantId,
};
if (
!["promoting", "submitting", "awaiting_code"].includes(
session.status,
) ||
(session.expiresAt && session.expiresAt.getTime() <= Date.now())
)
throw unprocessable("The login attempt is no longer active");
if (
!session.aiConnection ||
session.aiConnection.provider !== input.provider ||
session.aiConnection.method !== input.method ||
session.aiConnection.connectionId !== input.connectionId ||
session.aiConnection.ownership !== input.ownership ||
session.aiConnection.allAgents !== input.allAgents ||
JSON.stringify(session.aiConnection.agentIds) !==
JSON.stringify(input.agentIds)
)
throw forbidden("Login target mismatch");
}
if (reconnect) {
const [current] = await tx
.select()
.from(connectionGrants)
.where(eq(connectionGrants.id, grantId))
.for("update");
if (
!current ||
current.updatedAt.getTime() !== reconnect.grant.updatedAt.getTime() ||
current.updatedAt.getTime() > attemptStartedAt.getTime()
)
throw unprocessable("The connection changed. Start reconnect again.");
}
let secretId = reconnect?.grant.credentialSecretRefs.find(
(r) => r.configPath === "ai.credential",
)?.secretId;
// Adoption indexes existing credentials without transferring ownership.
// Only rotate the private slot created for this grant. A reconnect of an
// indexed credential must leave every legacy consumer's value untouched,
// even after adoption has cleared the connection's validation marker.
if (secretId) {
const [source] = await tx.select({ name: companySecrets.name, key: userSecretDefinitions.key })
.from(companySecrets)
.leftJoin(userSecretDefinitions, eq(userSecretDefinitions.id, companySecrets.userSecretDefinitionId))
.where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.id, secretId)));
const privateSlot = input.ownership === "personal"
? source?.key === `ai_${grantId.replaceAll("-", "_")}`
: source?.name === `ai-${grantId}`;
if (!privateSlot) secretId = undefined;
}
if (!verifiedCredential) { secretId = undefined; }
else if (secretId)
await secrets.rotate(
secretId,
{ value: verifiedCredential },
{ userId },
);
else if (input.ownership === "personal") {
const definition = await secrets.createUserSecretDefinition(
companyId,
{
key: `ai_${grantId.replaceAll("-", "_")}`,
name: input.name,
provider: "local_encrypted",
},
{ userId },
);
const secret = await secrets.createCurrentUserSecretValue(
companyId,
userId,
{ definitionId: definition.id, value: verifiedCredential },
{ userId },
);
secretId = secret.id;
} else
secretId = (
await secrets.create(
companyId,
{
name: `ai-${grantId}`,
provider: "local_encrypted",
value: verifiedCredential,
},
{ userId },
)
).id;
if (input.agentIds.length) {
const targets = await tx
.select({ id: agents.id })
.from(agents)
.where(
and(
eq(agents.companyId, companyId),
inArray(agents.id, input.agentIds),
),
);
if (targets.length !== new Set(input.agentIds).size)
throw forbidden("Agent does not belong to this company");
}
const source = aiConnectionCatalogSlug(input.provider, routing);
const providerName = getAppStoreDefinition(source)?.name ?? AI_CONNECTION_CAPABILITIES[input.provider].name;
const key = `app-gallery:${source}`;
await tx
.insert(toolApplications)
.values({
companyId,
applicationKey: key,
name: providerName,
type: "mcp_http",
metadata: { sourceTemplateKey: source },
ownerUserId: userId,
})
.onConflictDoNothing();
const [app] = await tx
.select()
.from(toolApplications)
.where(
and(
eq(toolApplications.companyId, companyId),
or(
eq(toolApplications.applicationKey, key),
eq(
toolApplications.name,
providerName,
),
),
),
);
if (!app) throw unprocessable("Could not find the provider application");
if (reconnect)
await tx
.update(toolConnections)
.set({
enabled: true,
status: "active",
healthStatus: "ok",
healthMessage: null,
config: { ...reconnect.connection.config, aiIsolatedSubscription: input.method === "subscription" && input.provider !== "anthropic" },
updatedAt: new Date(),
})
.where(eq(toolConnections.id, id));
else
await tx
.insert(toolConnections)
.values({
id,
companyId,
applicationId: app.id,
name: input.name,
uid: `ai-${id}`,
connectionPurpose: "ai",
transport: "runtime_auth",
authKind: input.method === "api_key" ? "api_key" : "oauth",
credentialPolicy:
input.ownership === "personal" ? "per_user" : "shared",
status: "active",
enabled: true,
healthStatus: "ok",
config: {
sourceTemplateKey: source,
ai: { provider: input.provider, method: input.method, ...(routing ? { routing } : {}) },
aiIsolatedSubscription: input.method === "subscription" && input.provider !== "anthropic",
},
createdByUserId: userId,
});
let accountLabel: string | undefined = input.provider === "google" ? verifiedAccountLabel : undefined;
if (input.method === "subscription" && input.provider !== "anthropic") {
try {
const credential = JSON.parse(verifiedCredential);
const claims = credential.tokens?.id_token
? JSON.parse(
Buffer.from(
credential.tokens.id_token.split(".")[1],
"base64url",
).toString(),
)
: credential;
const email = claims.email ?? claims.user?.email;
if (
typeof email === "string" &&
/^[^\s@/\\]{1,100}@[^\s@/\\]{1,100}\.[^\s@/\\]{2,40}$/.test(email)
)
accountLabel = email;
} catch {
/* Safe account identity is optional. */
}
}
const refs = secretId ? [
{
secretId: secretId!,
configPath: "ai.credential",
required: true,
versionSelector: "latest" as const,
},
] : [];
if (reconnect)
await tx
.update(connectionGrants)
.set({
status: "active",
providerTenant: accountLabel ? { name: accountLabel } : {},
credentialSecretRefs: refs,
revokedAt: null,
updatedAt: new Date(),
})
.where(eq(connectionGrants.id, grantId));
else
await tx
.insert(connectionGrants)
.values({
id: grantId,
companyId,
connectionId: id,
kind: input.ownership === "personal" ? "user" : "organization",
subjectUserId: input.ownership === "personal" ? userId : null,
isDefault: input.ownership === "shared",
providerTenant: accountLabel ? { name: accountLabel } : {},
credentialSecretRefs: refs,
createdByUserId: userId,
});
const [savedConnection] = await tx
.select()
.from(toolConnections)
.where(eq(toolConnections.id, id));
await syncConnectionCredentialBindings(tx, savedConnection, refs);
if (input.ownership === "personal" && !routing) {
await tx
.insert(aiConnectionDefaults)
.values({
companyId,
userId,
provider: input.provider,
method: input.method,
grantId,
})
.onConflictDoNothing();
await tx.insert(aiProviderDefaults).values({ companyId, userId, provider: input.provider, grantId }).onConflictDoNothing();
}
if (!reconnect) {
const installs = input.allAgents
? [{ targetType: "company" as const, targetId: companyId }]
: input.agentIds.map((targetId) => ({
targetType: "agent" as const,
targetId,
}));
if (installs.length)
await tx
.insert(toolConnectionInstalls)
.values(
installs.map((i) => ({
...i,
companyId,
connectionId: id,
createdByUserId: userId,
})),
);
}
if (loginSession) await notifyLoginSessionChanged(tx);
if (loginSession)
await tx
.update(adapterAuthSessions)
.set({
connectionId: id,
connectionGrantId: grantId,
connectionMethod: input.method,
})
.where(
eq(adapterAuthSessions.id, loginSession.id),
);
await loginSession?.complete?.(tx as unknown as Db);
await logActivity(tx as unknown as Db, {
companyId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: userId }),
action: reconnect
? "ai_connection.reconnected"
: "ai_connection.connected",
entityType: "tool_connection",
entityId: id,
details: { provider: input.provider, method: input.method, grantId },
});
return { connectionId: id, grantId };
}));
// Quota refresh can hold this lock through a 60-second token exchange.
// Keep the verified sign-in in memory across two 30-second acquisition
// timeouts instead of making its caller discard a successful login.
for (let attempt = 0; ; attempt++) {
try { return await persist(); }
catch (error) {
if (attempt >= 2 || (error as NodeJS.ErrnoException)?.code !== WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE) throw error;
}
}
}
async function recoverQuotaCredentialInTransaction(
tx: Db, row: Parameters<typeof credential>[0], current: string, onRecoveryStarted?: () => void,
) {
const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai);
if (!metadata.success || !["openai", "google"].includes(metadata.data.provider) || metadata.data.method !== "subscription") return null;
const recovery = quotaCredentialRecovery(row.connection.companyId, row.grant.id);
const pending = await recovery.read();
if (!pending) return null;
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential");
if (!ref || (pending.provider ?? "openai") !== metadata.data.provider
|| pending.secretId !== ref.secretId || pending.connectionId !== row.connection.id || pending.baseHash !== quotaCredentialHash(current)) {
await recovery.clear();
return null;
}
if (metadata.data.provider === "google") {
const [secret] = await tx.select({ epoch: companySecrets.aiSessionEpoch, version: companySecrets.latestVersion })
.from(companySecrets).where(and(eq(companySecrets.companyId, row.connection.companyId), eq(companySecrets.id, ref.secretId))).for("update");
if (!pending.google || pending.google.sessionEpoch !== secret?.epoch || pending.google.baseVersion !== secret?.version
|| pending.google.versionSelector !== (ref.versionSelector ?? "latest")
|| pending.google.accountEmail.toLowerCase() !== row.grant.providerTenant?.name?.toLowerCase()) {
await recovery.clear();
return null;
}
if (!pending.google.verified) {
onRecoveryStarted?.();
throw new Error("Google credential recovery needs account verification");
}
}
onRecoveryStarted?.();
await secretService(tx).rotate(ref.secretId, { value: pending.value, preserveAiSessionEpoch: true }, { userId: row.grant.subjectUserId });
// Automatic rotation changes secret revision, not the grant mutation marker
// used to fence reconnect attempts. Ownership and access are unchanged.
await logActivity(tx, { companyId: row.connection.companyId, actorType: "system", actorId: "quota_recovery",
action: "ai_connection.credential_refreshed", entityType: "tool_connection", entityId: row.connection.id,
details: { provider: metadata.data.provider, grantId: row.grant.id } });
return pending.value;
}
async function runtimeCredential(row: Parameters<typeof credential>[0]) {
const metadata = aiConnectionMetadataSchema.safeParse(row.connection.config.ai);
if (!metadata.success || !["openai", "google"].includes(metadata.data.provider) || metadata.data.method !== "subscription") return credential(row);
if (!(await quotaCredentialRecovery(row.connection.companyId, row.grant.id).read())) return credential(row);
let savingReplacement = false;
return withAccountHomeSecretMutationLock(undefined, row.connection.companyId, async () => {
if (metadata.data.provider === "google") {
const recovery = quotaCredentialRecovery(row.connection.companyId, row.grant.id);
const pending = await recovery.read();
if (pending?.provider === "google" && pending.google && !pending.google.verified
&& pending.connectionId === row.connection.id
&& pending.google.accountEmail.toLowerCase() === row.grant.providerTenant?.name?.toLowerCase()) {
const ref = row.grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential");
const [secret] = await db.select({ epoch: companySecrets.aiSessionEpoch, version: companySecrets.latestVersion })
.from(companySecrets).where(and(eq(companySecrets.companyId, row.connection.companyId), eq(companySecrets.id, pending.secretId)));
const current = await credential(row);
if (ref?.secretId === pending.secretId && (ref.versionSelector ?? "latest") === pending.google.versionSelector
&& secret?.epoch === pending.google.sessionEpoch && secret?.version === pending.google.baseVersion
&& pending.baseHash === quotaCredentialHash(current)) {
savingReplacement = true;
// Network verification runs before acquiring database row locks.
// The transaction below rechecks the exact saved generation.
await prepareAntigravityCredentialRecovery({ originalCredential: current,
refreshedCredential: pending.value, accountEmail: pending.google.accountEmail,
scope: { companyId: row.connection.companyId, accountKey: row.grant.id },
persistVerified: value => recovery.write({ ...pending, value, google: { ...pending.google!, verified: true } }),
});
}
}
}
let recovered = false;
const value = await db.transaction(async tx => {
const [grant] = await tx.select().from(connectionGrants).where(and(
eq(connectionGrants.id, row.grant.id), eq(connectionGrants.companyId, row.connection.companyId),
eq(connectionGrants.connectionId, row.connection.id),
)).for("update", { noWait: true });
if (!grant || grant.status !== "active") throw new Error("credentials_unavailable");
const ref = grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential");
if (!ref) throw new Error("credentials_unavailable");
await tx.select({ id: companySecrets.id }).from(companySecrets).where(and(
eq(companySecrets.id, ref.secretId), eq(companySecrets.companyId, row.connection.companyId),
)).for("update", { noWait: true });
const selected = { connection: row.connection, grant };
const current = await aiConnectionService(tx as unknown as Db).credential(selected);
const pending = await recoverQuotaCredentialInTransaction(tx as unknown as Db, selected, current, () => {
savingReplacement = true;
});
recovered = pending !== null;
return pending ?? current;
});
if (recovered) await quotaCredentialRecovery(row.connection.companyId, row.grant.id).clear()
.catch(error => logger.warn({ err: error, grantId: row.grant.id }, "Saved runtime credential recovery cleanup pending"));
return value;
}).catch(error => {
// Once a matching replacement is found, a failed write or commit must
// defer execution. Keep its journal so the next attempt can save it.
if (savingReplacement) {
throw unprocessable("AI credentials are being updated. Retry shortly.", { code: "ai_connection_busy" });
}
// Drizzle wraps Postgres errors in `cause`. Contention is a temporary
// pre-provider wait, not evidence that this account needs reconnecting.
let current: unknown = error;
for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) {
const value = current as { code?: unknown; cause?: unknown };
if (value.code === WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE || value.code === "55P03") {
throw unprocessable("AI credentials are being updated. Retry shortly.", { code: "ai_connection_busy" });
}
current = value.cause;
}
throw error;
});
}
/** A late failure must never invalidate credentials that were refreshed or reconnected meanwhile. */
async function markAuthenticationFailed(input: {
companyId: string;
runId?: string;
agentId?: string;
runStartedAt: Date;
attribution: AiConnectionAttribution & { identity: string };
}) {
return withAccountHomeSecretMutationLock(undefined, input.companyId, async () => {
let recovered = false;
await db.transaction(async (tx) => {
const { attribution } = input;
const [grant] = await tx.select().from(connectionGrants).where(and(
eq(connectionGrants.companyId, input.companyId),
eq(connectionGrants.id, attribution.grantId),
eq(connectionGrants.connectionId, attribution.connectionId),
)).for("update");
if (!grant || grant.status !== "active" || grant.updatedAt > input.runStartedAt) return;
const [connection] = await tx.select().from(toolConnections).where(and(
eq(toolConnections.companyId, input.companyId),
eq(toolConnections.id, attribution.connectionId),
eq(toolConnections.connectionPurpose, "ai"),
));
if (!connection) return;
const metadata = aiConnectionMetadataSchema.safeParse(connection.config.ai);
if (!metadata.success || metadata.data.provider !== attribution.provider || metadata.data.method !== attribution.method) return;
const ref = grant.credentialSecretRefs.find((candidate) => candidate.configPath === "ai.credential");
if (!ref) return;
await tx.select({ id: companySecrets.id }).from(companySecrets).where(and(
eq(companySecrets.companyId, input.companyId), eq(companySecrets.id, ref.secretId),
)).for("update");
const value = await aiConnectionService(tx as unknown as Db).credential({ connection, grant });
const generation = createHash("sha256").update(value).digest("hex").slice(0, 16);
if (attribution.identity !== `${grant.id}:${attribution.responsibleUserId ?? "shared"}:${generation}`) return;
// A quota exchange may have consumed this run's token before a failed
// database commit. Save its working replacement instead of revoking it.
recovered = await recoverQuotaCredentialInTransaction(tx as unknown as Db, { connection, grant }, value) !== null;
if (recovered) return;
await tx.update(connectionGrants).set({ status: "needs_reauthorization", updatedAt: new Date() })
.where(eq(connectionGrants.id, grant.id));
await tx.update(toolConnections).set({ healthStatus: "error", healthMessage: "Sign in again to restore this AI connection.", updatedAt: new Date() })
.where(eq(toolConnections.id, connection.id));
await logActivity(tx as unknown as Db, {
companyId: input.companyId, actorType: "system", actorId: input.runId ? "heartbeat" : "adapter_test",
agentId: input.agentId, runId: input.runId, action: "ai_connection.authentication_failed",
entityType: "tool_connection", entityId: connection.id,
details: { provider: attribution.provider, grantId: grant.id },
});
});
if (recovered) await quotaCredentialRecovery(input.companyId, input.attribution.grantId).clear()
.catch(error => logger.warn({ err: error, grantId: input.attribution.grantId }, "Saved failed-run credential recovery cleanup pending"));
});
}
/** Refresh only the selected vaulted identity, serializing rotation and reconnect. */
async function refreshQuotaCredential(row: Parameters<typeof credential>[0], failedValue: string, signal: AbortSignal) {
return withAccountHomeSecretMutationLock(undefined, row.connection.companyId, async () => {
const recovery = quotaCredentialRecovery(row.connection.companyId, row.grant.id);
let replacement: { value: string; secretId: string; version: number; epoch: number; base: string; grantUpdatedAt: number; versionSelector: "latest" | number } | undefined;
const save = () => db.transaction(async (tx) => {
// Do not wait on a transaction that might itself be waiting for the file
// lock. All database locks must be available before consuming OAuth tokens.
const companyId = row.connection.companyId;
const [grant] = await tx.select().from(connectionGrants).where(and(
eq(connectionGrants.id, row.grant.id), eq(connectionGrants.companyId, companyId),
eq(connectionGrants.connectionId, row.connection.id),
)).for("update", { noWait: true });
if (!grant || grant.status !== "active") throw new Error("credentials_unavailable");
const ref = grant.credentialSecretRefs.find(ref => ref.configPath === "ai.credential");
if (!ref) throw new Error("credentials_unavailable");
const [secret] = await tx.select({ id: companySecrets.id, version: companySecrets.latestVersion, epoch: companySecrets.aiSessionEpoch }).from(companySecrets).where(and(
eq(companySecrets.id, ref.secretId), eq(companySecrets.companyId, companyId),
)).for("update", { noWait: true });
if (!secret) throw new Error("credentials_unavailable");
const current = await aiConnectionService(tx as unknown as Db).credential({ connection: row.connection, grant });
if (replacement) {
// A lost commit response is reconciled from the authoritative secret.
// Reconnect, revocation and a competing saved generation always win.
if (current === replacement.value) return current;
if (ref.secretId !== replacement.secretId || secret.version !== replacement.version
|| secret.epoch !== replacement.epoch || current !== replacement.base
|| grant.updatedAt.getTime() !== replacement.grantUpdatedAt
|| (ref.versionSelector ?? "latest") !== replacement.versionSelector) return current;
} else signal.throwIfAborted();
let pending = await recovery.read();
if (pending && (pending.secretId !== ref.secretId || pending.connectionId !== row.connection.id || pending.baseHash !== quotaCredentialHash(current))) {
// Reconnect may reuse this grant. Once the current authorized token
// itself expires, an older journal must not prevent refreshing it.
await recovery.clear();
pending = null;
}
// A delayed poll may have read A before B was saved and B's refresh
// produced an uncommitted C. Recover C before returning or clearing its
// journal; a newer database credential alone does not prove it is usable.
if (current !== failedValue && !pending) return current;
// An active provider may rotate this single-use token itself. Defer polling
// until its credential write-back instead of invalidating its live copy.
const [active] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.innerJoin(agents, and(eq(agents.id, heartbeatRuns.agentId), eq(agents.companyId, companyId))).where(and(
eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, ["queued", "running"]),
or(sql`${heartbeatRuns.contextSnapshot}->'aiConnection'->>'grantId' = ${grant.id}`,
sql`${agents.runtimeConfig}->'aiConnection'->>'provider' = 'openai'`, eq(agents.adapterType, "codex_local")),
)).limit(1);
if (active && !pending && !replacement) throw new Error("provider_unavailable");
let refreshed = replacement?.value ?? pending?.value;
if (!refreshed) {
const auth = JSON.parse(current);
const refreshToken = auth.tokens?.refresh_token;
if (typeof refreshToken !== "string" || !refreshToken) throw new Error("authentication_required");
// Once exchange starts, its lifetime is independent of the dashboard's
// shorter deadline. A consumed single-use token must still be read/saved.
signal.throwIfAborted();
const refreshSignal = AbortSignal.timeout(60_000);
// Matches the Codex OAuth client (openai/codex, login/src/auth/manager.rs).
const response = await fetch("https://auth.openai.com/oauth/token", {
method: "POST", redirect: "error", signal: refreshSignal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: "app_EMoamEEZ73f0CkXaXp7hrann", grant_type: "refresh_token", refresh_token: refreshToken }),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(response.status === 400 || response.status === 401 ? "authentication_required" : "provider_unavailable");
}
const tokens = await response.json() as Record<string, unknown>;
if (typeof tokens.access_token !== "string" || !tokens.access_token) throw new Error("provider_unavailable");
refreshed = JSON.stringify({ ...auth, tokens: { ...auth.tokens,
access_token: tokens.access_token,
...(typeof tokens.refresh_token === "string" && tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}),
...(typeof tokens.id_token === "string" && tokens.id_token ? { id_token: tokens.id_token } : {}),
}, last_refresh: new Date().toISOString() });
}
// Only this logical refresh owns the replacement. Save retries must not
// consume another single-use token. Process loss before commit can require
// signing in again in v1; no secondary credential vault is created.
replacement ??= { value: refreshed, secretId: ref.secretId, version: secret.version, epoch: secret.epoch, base: current,
grantUpdatedAt: grant.updatedAt.getTime(), versionSelector: ref.versionSelector ?? "latest" };
// Persist the rotated token even if the quota deadline expires after the
// successful exchange; losing it would strand all subsequent executions.
await secretService(tx).rotate(ref.secretId, { value: refreshed, preserveAiSessionEpoch: true }, { userId: grant.subjectUserId });
await logActivity(tx as unknown as Db, { companyId, actorType: "system", actorId: "quota",
action: "ai_connection.credential_refreshed", entityType: "tool_connection", entityId: row.connection.id,
details: { provider: "openai", grantId: grant.id } });
return refreshed;
});
for (let attempt = 0; ; attempt++) {
try {
const value = await save();
// Clear only after commit. A committed-but-lost response is detected
// by the current credential comparison on the next attempt.
await recovery.clear().catch((error) => logger.warn({ err: error, grantId: row.grant.id }, "Saved quota credential recovery cleanup pending"));
return value;
} catch (error) {
if (!replacement || attempt >= 2) throw error;
// Retry only the returned value. Required database reads still fail
// closed; exhaustion is an error, never a successful refresh.
}
}
});
}
// A quota read uses the same credential audience as execution. Operator status
// alone never grants access to another member's personal subscription.
async function quotaAccounts(companyId: string, userId: string, includeOtherProviders = false) {
if (!(await membership(companyId, userId))) return [];
const visible = await list(companyId, userId);
const accounts = await rows(companyId);
return accounts.flatMap(row => {
const summary = visible.find(item => item.id === row.connection.id && item.grantId === row.grant.id);
if (!summary || summary.method !== "subscription" || !(includeOtherProviders ? ["openai", "anthropic", "xai"] : ["openai", "anthropic"]).includes(summary.provider)) return [];
return [{ ...row, summary }];
});
}
return { list, selectDecision, selectFastResponse, selectImageGeneration, selectPromptInjectionPrevention, quotaAccounts, subscriptionAccounts: (companyId: string, userId: string) => quotaAccounts(companyId, userId, true), refreshQuotaCredential, select, credential, runtimeCredential, probeUsage, accountIdentity, save, setDefault, membership, markAuthenticationFailed };
}