diff --git a/doc/connections/AI-CONNECTION-ROUTERS.md b/doc/connections/AI-CONNECTION-ROUTERS.md index cb2ee1e2bb9..06f092c556f 100644 --- a/doc/connections/AI-CONNECTION-ROUTERS.md +++ b/doc/connections/AI-CONNECTION-ROUTERS.md @@ -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 diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index d4f28de63d0..9a8df492c43 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -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>; let db: ReturnType; @@ -67,7 +70,75 @@ function hired(response: request.Response) { return response.body.agent ?? response.body; } +async function poolFixture(f: Awaited>, 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"); diff --git a/server/src/__tests__/ai-legacy-compatibility.test.ts b/server/src/__tests__/ai-legacy-compatibility.test.ts index bc41cd3181e..ee9a3e05a62 100644 --- a/server/src/__tests__/ai-legacy-compatibility.test.ts +++ b/server/src/__tests__/ai-legacy-compatibility.test.ts @@ -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"); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 52b097f150d..7c800ac291b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3397,13 +3397,30 @@ export function agentRoutes( async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record, 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(); + let compatible = false; + for (const member of pool.members) { + let runtime: ReturnType; + 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; 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")) { diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index c0dd80270d9..b5eb7201f8a 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -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); diff --git a/server/src/services/ai-connection-router.ts b/server/src/services/ai-connection-router.ts index c33b87394a1..4208bcd775a 100644 --- a/server/src/services/ai-connection-router.ts +++ b/server/src/services/ai-connection-router.ts @@ -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); diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index 88cd475f2b2..ea691f5f3db 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -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: { diff --git a/server/src/services/ai-connections.ts b/server/src/services/ai-connections.ts index a3d2bf640c5..7cfdbeb1551 100644 --- a/server/src/services/ai-connections.ts +++ b/server/src/services/ai-connections.ts @@ -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") ) )