From 384a66568c97720c4ec3ca305701742effb70abd Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 5 Oct 2026 15:39:14 -0500 Subject: [PATCH] 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 --- doc/connections/AI-CONNECTION-ROUTERS.md | 10 ++- .../agent-hire-ai-connections.test.ts | 75 ++++++++++++++++++- .../__tests__/ai-legacy-compatibility.test.ts | 17 ++++- server/src/routes/agents.ts | 43 ++++++++--- server/src/services/agents.ts | 14 ++-- server/src/services/ai-connection-router.ts | 2 +- server/src/services/ai-connection-runtime.ts | 2 +- server/src/services/ai-connections.ts | 2 +- 8 files changed, 138 insertions(+), 27 deletions(-) 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") ) )