Validate pool installs and reconnect session identity

Validate member access for saved agents, install only authorized members during atomic agent creation, recheck pool harness changes, and include the credential slot in stable session and usage-cache identity.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-05 15:39:14 -05:00
1 parent 7473e1a337
commit 384a66568c
8 files changed
+138 -27

No files matched your search

+8 -2
View File
@@ -12,7 +12,11 @@ fleet or per-stack managed feature configuration. Installing a plugin does not
enable routing; enabling routing does not enable individual pools.
The host authorizes members using the ordinary company, user, sharing, install,
health and harness checks. It sends authorized metadata and sanitized usage
health and harness checks. Saving a binding or changing its harness requires
at least one usable member for that agent. New-agent creation installs only
members authorized by the ordinary personal-owner or shared-install policy,
in the same transaction as the agent. Pool membership cannot install a
restricted shared connection. It sends authorized metadata and sanitized usage
observations to `onRouteAiConnection`. The plugin proposes an opaque member ID;
it cannot receive credentials or expand authorization. Pure round robin does
not probe usage. Usage-aware selection has a shared 15-second probe budget,
@@ -39,7 +43,9 @@ eligible member; otherwise the operator must explicitly reset the session.
Credential `ai_session_epoch` changes on reconnect or manual rotation. Only
verified runtime refresh write-back preserves it. Session fingerprints use the
epoch while authentication failure attribution still uses the token generation.
secret ID and epoch while authentication failure attribution still uses the
token generation. A reconnect that replaces an indexed legacy secret changes
the session identity even when both secret epochs are zero.
Adopting a valid account preserves a session only when the complete effective
configuration matches a prior fingerprint. Core can bridge binding-only agent
revisions (up to 20) and unchanged legacy token identities at epoch zero. Changes
@@ -6,8 +6,8 @@ import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, companies, companyMemberships, createDb, heartbeatRuns, issues, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db";
import { type AiConnectionBinding } from "@paperclipai/shared";
import { activityLog, agents, companies, companyMemberships, createDb, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db";
import { type AiConnectionBinding, type AiConnectionPoolMember, type PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
@@ -16,6 +16,9 @@ import { heartbeatService } from "../services/heartbeat.js";
import { getServerAdapter, registerServerAdapter, unregisterServerAdapter } from "../adapters/index.js";
import { prepareManagedAiRuntime } from "../services/ai-connection-runtime.js";
import { secretService } from "../services/secrets.js";
import { aiConnectionRouterService } from "../services/ai-connection-router.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { toolAccessService } from "../services/tool-access.js";
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
@@ -67,7 +70,75 @@ function hired(response: request.Response) {
return response.body.agent ?? response.body;
}
async function poolFixture(f: Awaited<ReturnType<typeof fixture>>, extraMembers: AiConnectionPoolMember[] = []) {
await instanceSettingsService(db).updateExperimental({ enableAiConnectionRouters: true });
const pluginKey = `fixture.pool-${f.companyId}`;
const manifest: PaperclipPluginManifestV1 = { id: pluginKey, apiVersion: 1, version: "0.1.0", displayName: "Pool fixture", description: "Fixture", author: "Tests", categories: ["connector"], capabilities: ["ai.connections.route"], aiConnectionRouter: { name: "AI connection pool", description: "Fixture" }, entrypoints: { worker: "worker.js" } };
await db.insert(plugins).values({ pluginKey, packageName: pluginKey, version: "0.1.0", manifestJson: manifest, status: "ready" });
const member: AiConnectionPoolMember = { id: randomUUID(), binding: { ...f.account, provider: "openai", method: "api_key", mode: "delegated" }, profile: { provider: "codex", model: "gpt-5.6-sol" } };
const pool = await aiConnectionRouterService(db).save(pluginKey, { companyId: f.companyId, config: { name: "Fixture pool", enabled: true, mode: "round_robin", thresholdPercent: 90, members: [member, ...extraMembers] } }, f.userId);
return { pool, binding: { mode: "router", connectionId: pool.id } as const, member };
}
describe("agent-created hires use managed AI connections", () => {
it.each(["agent-hires", "agents"])("%s installs authorized pool members atomically with the new agent", async (endpoint) => {
const f = await fixture("openai");
const p = await poolFixture(f);
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/${endpoint}`).send({ name: "Pool teammate", role: "engineer", adapterType: f.adapterType, reportsTo: f.agentId, runtimeConfig: { aiConnection: p.binding } }));
expect(agent.runtimeConfig.aiConnection).toEqual(p.binding);
const installs = await db.select().from(toolConnectionInstalls).where(and(eq(toolConnectionInstalls.companyId, f.companyId), eq(toolConnectionInstalls.targetType, "agent"), eq(toolConnectionInstalls.targetId, agent.id)));
expect(installs.map(i => i.connectionId).sort()).toEqual([p.pool.id, f.account.connectionId].sort());
const [creation] = await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.entityId, agent.id), eq(activityLog.action, endpoint === "agent-hires" ? "agent.hire_created" : "agent.created")));
expect(creation.details).toMatchObject({ aiConnectionPoolId: p.pool.id, aiConnectionMemberInstallIds: [f.account.connectionId] });
const runtime = await prepareManagedAiRuntime(db, { companyId: f.companyId, agentId: agent.id, responsibleUserId: f.userId, adapterType: f.adapterType, binding: p.member.binding, config: {} });
try { expect(runtime.attribution.connectionId).toBe(f.account.connectionId); } finally { await runtime.cleanup(); }
});
it("rejects a saved pool binding when no member is installed for that agent", async () => {
const f = await fixture("openai");
const p = await poolFixture(f);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
await db.delete(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, f.account.connectionId));
const response = await request(f.app).patch(`/api/agents/${f.agentId}`).send({ runtimeConfig: { aiConnection: p.binding } });
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.details).toMatchObject({ code: "ai_connection_pool_no_eligible_member" });
const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(saved.runtimeConfig.aiConnection).toEqual(f.binding);
});
it("does not install a restricted shared connection through pool membership", async () => {
const f = await fixture("openai");
const otherOwner = `other-${f.companyId}`;
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: otherOwner, membershipRole: "member", status: "active" });
const account = await aiConnectionService(db).save(f.companyId, otherOwner, { provider: "openai", method: "api_key", ownership: "shared", name: "Shared credential, restricted installation", apiKey: "fixture", agentIds: [f.agentId], allAgents: false }, "fixture");
await toolAccessService(db).replaceConnectionGrantMembers(account.connectionId, account.grantId, [f.userId], { userId: otherOwner });
const member: AiConnectionPoolMember = { id: randomUUID(), binding: { ...account, provider: "openai", method: "api_key", mode: "shared" }, profile: { provider: "codex", model: "gpt-5.6-sol" } };
const p = await poolFixture(f, [member]);
const agent = hired(await request(f.app).post(`/api/companies/${f.companyId}/agents`).send({ name: "Restricted pool teammate", role: "engineer", adapterType: f.adapterType, runtimeConfig: { aiConnection: p.binding } }));
const input = { companyId: f.companyId, userId: f.userId, adapterType: f.adapterType, binding: member.binding };
expect((await aiConnectionService(db).select({ ...input, agentId: f.agentId })).connection.id).toBe(account.connectionId);
await expect(aiConnectionService(db).select({ ...input, agentId: agent.id })).rejects.toThrow("not permitted for this agent");
const installs = await db.select().from(toolConnectionInstalls).where(eq(toolConnectionInstalls.connectionId, account.connectionId));
expect(installs.map(i => i.targetId)).toEqual([f.agentId]);
});
it.each([false, true])("rechecks an unchanged pool when the agent harness changes (compatible member: %s)", async (compatible) => {
const f = await fixture("openai");
const extra: AiConnectionPoolMember[] = [];
if (compatible) {
const account = await aiConnectionService(db).save(f.companyId, f.userId, { provider: "anthropic", method: "api_key", ownership: "personal", name: "Claude fixture", apiKey: "fixture", allAgents: true, agentIds: [] }, "fixture");
extra.push({ id: randomUUID(), binding: { ...account, provider: "anthropic", method: "api_key", mode: "delegated" }, profile: { provider: "acpx", acpxAgent: "claude", model: "claude-sonnet-5" } });
}
const p = await poolFixture(f, extra);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" });
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
const response = await request(f.app).patch(`/api/agents/${f.agentId}`).send({ adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-5" } });
expect(response.status, JSON.stringify(response.body)).toBe(compatible ? 200 : 422);
const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId));
expect(saved.adapterType).toBe(compatible ? "claude_local" : "codex_local");
expect(saved.runtimeConfig.aiConnection).toEqual(p.binding);
});
for (const operation of ["test", "save"] as const) {
it.each([401, 403, 429, 503, null])(`${operation} changes API-key health only for a provider rejection (status: %s)`, async (status) => {
const f = await fixture("anthropic", "api_key");
@@ -4,11 +4,11 @@ import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { eq, sql } from "drizzle-orm";
import { createDb, companies, agents, companyMemberships, adapterAuthSessions, environments, connectionGrants, toolConnections, activityLog } from "@paperclipai/db";
import { createDb, companies, agents, companyMemberships, adapterAuthSessions, environments, connectionGrants, toolConnections, activityLog, companySecrets } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "@paperclipai/db/test-embedded-postgres";
import { secretService } from "../services/secrets.js";
import { aiConnectionService } from "../services/ai-connections.js";
import { prepareManagedAiRuntime } from "../services/ai-connection-runtime.js";
import { prepareManagedAiRuntime, managedAiSessionFingerprintConfig } from "../services/ai-connection-runtime.js";
import { localAiLoginService } from "../services/local-ai-login.js";
import { readVerifiedLocalAiCredential } from "../services/local-ai-credentials.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
@@ -39,12 +39,25 @@ it("reconnect detaches an indexed credential without changing unadopted legacy a
const migration = await readFile(new URL("../../../packages/db/src/migrations/0276_hard_mandroid.sql", import.meta.url), "utf8");
await db.execute(sql.raw(migration.slice(migration.indexOf("DO $$", migration.indexOf("-- Only declared")))));
const connection = (await service.list(companyId, owner)).find(c => c.name === secret.name)!;
const input = { companyId, agentId, responsibleUserId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "delegated", connectionId: connection.id, grantId: connection.grantId } as const, config: {}, allowLegacyValidation: true };
const before = await prepareManagedAiRuntime(db, input);
await before.cleanup();
const resolve = () => vault.resolveUserSecretValue(companyId, { definitionId: definition.id, responsibleUserId: owner, required: true, version: "latest" }, { companyId, responsibleUserId: owner, actorType: "system" });
expect((await resolve())?.value).toBe("fixture-original");
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-new-account" }, "fixture-new-account");
expect((await resolve())?.value).toBe("fixture-original");
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, connection.grantId));
expect(grant.credentialSecretRefs[0].secretId).not.toBe(secret.id);
const [oldSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id));
const [newSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, grant.credentialSecretRefs[0].secretId));
expect(oldSecret.aiSessionEpoch).toBe(0);
expect(newSecret.aiSessionEpoch).toBe(0);
const after = await prepareManagedAiRuntime(db, input);
try {
expect(after.attribution.grantId).toBe(before.attribution.grantId);
expect(after.sessionIdentity).not.toBe(before.sessionIdentity);
expect(managedAiSessionFingerprintConfig(after.config, after.home)).not.toEqual(managedAiSessionFingerprintConfig(before.config, before.home));
} finally { await after.cleanup(); }
const selected = await service.select({ companyId, agentId, userId: owner, adapterType: "claude_local", binding: { provider: "anthropic", method: "api_key", mode: "responsible_user" } });
expect(await service.credential(selected)).toBe("fixture-new-account");
await service.save(companyId, owner, { provider: "anthropic", method: "api_key", name: connection.name, ownership: "personal", agentIds: [], allAgents: true, connectionId: connection.id, apiKey: "fixture-second" }, "fixture-second");
+31 -12
View File
@@ -3397,13 +3397,30 @@ export function agentRoutes(
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) {
if (binding.mode === "router") {
const settings = await import("../services/instance-settings.js");
if (!(await settings.instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) throw unprocessable("Ask your instance operator to enable AI connection routing.");
const pool = (await aiConnectionRouterService(db).selectable(companyId, responsibleUserForAiRequest(req) ?? "")).find((p) => p.id === binding.connectionId && p.enabled);
if (!pool) throw unprocessable("Enable the selected connection pool");
if (!pool.members.some((m) => { try { poolMemberRuntimeConfig(m, adapterType); return true; } catch { return false; } })) throw unprocessable("This pool has no compatible harness");
return pool.id;
const userId = responsibleUserForAiRequest(req);
const memberConnectionIds = new Set<string>();
let compatible = false;
for (const member of pool.members) {
let runtime: ReturnType<typeof poolMemberRuntimeConfig>;
try { runtime = poolMemberRuntimeConfig(member, adapterType); } catch { continue; }
compatible = true;
const allowUninstalledShared = newAgent && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, member.binding);
try {
const selection = await aiConnectionService(db).select({ companyId, agentId, userId, adapterType, binding: member.binding,
model: runtime.config.model, runnerProvider: runtime.config.provider, acpxAgent: runtime.config.acpxAgent,
allowUninstalledPersonal: newAgent, allowUninstalledShared });
memberConnectionIds.add(selection.connection.id);
} catch (error) {
if (!(error instanceof HttpError && [403, 422].includes(error.status))) throw error;
}
}
if (!compatible) throw unprocessable("This pool has no compatible harness");
if (!memberConnectionIds.size) throw unprocessable("Install or repair a compatible pool member for this agent.", { code: "ai_connection_pool_no_eligible_member" });
return { connectionId: pool.id, ...(newAgent ? { memberConnectionIds: [...memberConnectionIds] } : {}) };
}
const userId = responsibleUserForAiRequest(req);
const allowUninstalledShared = newAgent && await canInstallSharedAiConnectionForNewAgent(db, req, companyId, binding);
@@ -3434,7 +3451,7 @@ export function agentRoutes(
if (selection.connection.config.aiLegacyAdoption === true) await db.update(toolConnections).set({ healthStatus: "ok", config: { ...selection.connection.config, aiLegacyAdoption: false }, updatedAt: new Date() }).where(eq(toolConnections.id, selection.connection.id));
} finally { try { await managed?.cleanup(); } finally { await target.release("released"); } }
}
return selection.connection.id;
return { connectionId: selection.connection.id };
}
router.post(
@@ -3462,7 +3479,7 @@ export function agentRoutes(
}
const poolBinding = requestedBinding ?? (savedAgent?.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(savedAgent.runtimeConfig.aiConnection) : undefined);
if (poolBinding?.mode === "router") {
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, req.body.adapterConfig ?? {}, poolBinding, req.body.environmentId, false);
await validateManagedAgentBinding(req, companyId, savedAgentId ?? "", type, req.body.adapterConfig ?? {}, poolBinding, req.body.environmentId, false, !savedAgentId);
res.json({ adapterType: type, status: "warn", testedAt: new Date().toISOString(), checks: [{ code: "ai_connection_pool_task_test_required", level: "warn", message: "Pool configuration is available. Run a task to verify the selected account and harness; this preview does not allocate a task or contact a provider." }] });
return;
}
@@ -4665,7 +4682,7 @@ export function agentRoutes(
const requiresApproval = company.requireBoardApprovalForNewAgents;
const status = requiresApproval ? "pending_approval" : "idle";
const managedHireBinding = normalizedHireInput.runtimeConfig?.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedHireInput.runtimeConfig.aiConnection) : undefined;
const managedHireConnectionId = managedHireBinding ? await validateManagedAgentBinding(req, companyId, hiredAgentId, normalizedHireInput.adapterType, normalizedHireInput.adapterConfig, managedHireBinding, normalizedHireInput.defaultEnvironmentId, false, true) : undefined;
const managedHireConnection = managedHireBinding ? await validateManagedAgentBinding(req, companyId, hiredAgentId, normalizedHireInput.adapterType, normalizedHireInput.adapterConfig, managedHireBinding, normalizedHireInput.defaultEnvironmentId, false, true) : undefined;
const createdAgent = await svc.create(
companyId,
{
@@ -4676,7 +4693,7 @@ export function agentRoutes(
lastHeartbeatAt: null,
},
{
aiConnectionInstall: managedHireConnectionId ? { connectionId: managedHireConnectionId, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
aiConnectionInstall: managedHireConnection ? { ...managedHireConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
claudeLogin: {
storedSessionId: hireStoredSessionId ?? null,
ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null),
@@ -4787,6 +4804,7 @@ export function agentRoutes(
issueIds: sourceIssueIds,
desiredSkills: desiredSkillAssignment.desiredSkills,
hireFingerprint: requestFingerprint,
...(managedHireConnection?.memberConnectionIds ? { aiConnectionPoolId: managedHireConnection.connectionId, aiConnectionMemberInstallIds: managedHireConnection.memberConnectionIds } : {}),
},
});
const telemetryClient = getTelemetryClient();
@@ -4914,7 +4932,7 @@ export function agentRoutes(
});
const managedBinding = normalizedRuntimeConfig.aiConnection ? aiRuntimeConnectionBindingSchema.parse(normalizedRuntimeConfig.aiConnection) : undefined;
const managedConnectionId = managedBinding ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined;
const managedConnection = managedBinding ? await validateManagedAgentBinding(req, companyId, agentId, createInput.adapterType, normalizedAdapterConfig, managedBinding, createInput.defaultEnvironmentId, false, true) : undefined;
const createdAgent = await svc.create(
companyId,
{
@@ -4927,7 +4945,7 @@ export function agentRoutes(
lastHeartbeatAt: null,
},
{
aiConnectionInstall: managedConnectionId ? { connectionId: managedConnectionId, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
aiConnectionInstall: managedConnection ? { ...managedConnection, createdByUserId: responsibleUserForAiRequest(req) } : undefined,
claudeLogin: {
storedSessionId: createStoredSessionId ?? null,
ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null),
@@ -4964,6 +4982,7 @@ export function agentRoutes(
name: agent.name,
role: agent.role,
desiredSkills: desiredSkillAssignment.desiredSkills,
...(managedConnection?.memberConnectionIds ? { aiConnectionPoolId: managedConnection.connectionId, aiConnectionMemberInstallIds: managedConnection.memberConnectionIds } : {}),
},
});
const telemetryClient = getTelemetryClient();
@@ -5442,8 +5461,8 @@ export function agentRoutes(
if (!setup.aiConnectionRequiresAdoption || !setup.aiConnection) throw conflict("The agent’s AI configuration changed. Reload the task and try again.");
// Probe in the agent's execution environment without installing access.
// The binding, install, audit, and card resolution commit together below.
const validatedConnectionId = await validateManagedAgentBinding(req, agent.companyId, agent.id, agent.adapterType, agent.adapterConfig, setup.aiConnection, agent.defaultEnvironmentId, true, true);
if (validatedConnectionId !== connectionId) throw conflict("This is no longer the selected account. Reload the task and try again.");
const validatedConnection = await validateManagedAgentBinding(req, agent.companyId, agent.id, agent.adapterType, agent.adapterConfig, setup.aiConnection, agent.defaultEnvironmentId, true, true);
if (validatedConnection?.connectionId !== connectionId) throw conflict("This is no longer the selected account. Reload the task and try again.");
res.json(await intents.complete(interactionId, connectionId, userId, {
validatedAdoption: { agentUpdatedAt: agent.updatedAt, binding: setup.aiConnection },
}));
@@ -5598,7 +5617,7 @@ export function agentRoutes(
const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection);
const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record<string, unknown>;
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
if (changed) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
if (changed || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null, true);
}
if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;
if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) {
+8 -6
View File
@@ -126,7 +126,7 @@ interface UpdateAgentOptions {
}
interface CreateAgentOptions {
aiConnectionInstall?: { connectionId: string; createdByUserId: string | null };
aiConnectionInstall?: { connectionId: string; memberConnectionIds?: string[]; createdByUserId: string | null };
allowBuiltInAgentMetadata?: boolean;
claudeLogin?: ClaudeLoginContext;
}
@@ -931,11 +931,13 @@ export function agentService(db: Db) {
.returning()
.then((rows) => rows[0]);
if (options?.aiConnectionInstall) {
await tx.insert(toolConnectionInstalls).values({
companyId, connectionId: options.aiConnectionInstall.connectionId,
targetType: "agent", targetId: created.id,
createdByUserId: options.aiConnectionInstall.createdByUserId,
}).onConflictDoNothing();
const install = options.aiConnectionInstall;
const connectionIds = [...new Set([install.connectionId, ...(install.memberConnectionIds ?? [])])];
await tx.insert(toolConnectionInstalls).values(connectionIds.map(connectionId => ({
companyId, connectionId,
targetType: "agent" as const, targetId: created.id,
createdByUserId: install.createdByUserId,
}))).onConflictDoNothing();
}
await syncAgentSecretBindings(created, txDb);
const normalizedCreated = await agentService(txDb).getById(created.id);
+1 -1
View File
@@ -171,7 +171,7 @@ export function aiConnectionRouterService(db: Db, workerManager?: PluginWorkerMa
if (!grant) return null;
const ref = grant.credentialSecretRefs.find((r) => r.configPath === "ai.credential");
const [secret] = ref ? await db.select({ version: companySecrets.latestVersion }).from(companySecrets).where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.id, ref.secretId))) : [];
return `${companyId}:${grant.id}:${secret?.version}:${grant.updatedAt.toISOString()}`;
return `${companyId}:${grant.id}:${ref?.secretId}:${secret?.version}:${grant.updatedAt.toISOString()}`;
}
async function probe(member: AiConnectionPoolMember, companyId: string, userId: string, deadline: number) {
const key = await usageCacheKey(member, companyId);
+1 -1
View File
@@ -311,7 +311,7 @@ export async function prepareManagedAiRuntime(
.digest("hex")
.slice(0, 16);
const identity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${generation}`;
const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${freshness.epoch}`;
const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${credentialRef.secretId}:${freshness.epoch}`;
return {
sessionIdentity,
config: {
+1 -1
View File
@@ -383,7 +383,7 @@ export function aiConnectionService(db: Db) {
!installs.length &&
!(
(input.allowUninstalledPersonal &&
binding.mode === "responsible_user" && grant.subjectUserId === userId) ||
grant.kind === "user" && grant.subjectUserId === userId) ||
(input.allowUninstalledShared && binding.mode === "shared" && grant.kind === "organization")
)
)