diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index f828a2c85e0..f06bb5c943b 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2146,6 +2146,13 @@ Lifecycle, timeout, and interrupt grace settings live under **Advanced Run Policy**. Permission selectors with a single valid mode are hidden; a saved unsupported mode still exposes remediation. +Saving an existing agent compares AI connection bindings by value, independent +of PostgreSQL JSONB key order. Identity, thinking-effort and run-policy edits +avoid the connection-adoption runtime probe when the connection and execution +selection stay the same. Connection, harness, model, provider, engine and +environment changes still validate adoption. Explicit runtime tests and +required Copilot metadata checks keep their existing behavior. + Model catalogs and refresh follow the selected provider. ACPX Claude uses the normal Claude catalog and accepts custom model IDs; the exact ID is sent to Claude, which can reject unavailable models. Package/version verification is diff --git a/server/src/__tests__/agent-hire-ai-connections.test.ts b/server/src/__tests__/agent-hire-ai-connections.test.ts index cba65a86d5a..2b397429aa8 100644 --- a/server/src/__tests__/agent-hire-ai-connections.test.ts +++ b/server/src/__tests__/agent-hire-ai-connections.test.ts @@ -9,7 +9,7 @@ import express from "express"; import request from "supertest"; import { and, eq } from "drizzle-orm"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeRegisteredClients, heartbeatRuns, issues, plugins, principalPermissionGrants, toolConnectionInstalls } from "@paperclipai/db"; +import { activityLog, agents, authUsers, companies, companyMemberships, createDb, closeRegisteredClients, environments, 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"; @@ -209,6 +209,78 @@ describe("agent-created hires use managed AI connections", () => { expect(saved.runtimeConfig.aiConnection).toEqual(f.binding); }); + it.each(["shared", "delegated", "responsible_user", "router"] as const)("saves unrelated edits without probing an unchanged %s connection read from PostgreSQL", async (mode) => { + const f = await fixture("openai"); + await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" }); + const account = mode === "shared" + ? await aiConnectionService(db).save(f.companyId, f.userId, { + provider: "openai", method: "api_key", ownership: "shared", name: "Shared save fixture", + apiKey: "fixture", agentIds: [f.agentId], allAgents: false, + }, "fixture") + : f.account; + const binding = mode === "router" ? (await poolFixture(f)).binding + : mode === "responsible_user" ? f.binding + : { provider: "openai", method: "api_key", mode, ...account }; + const adapterType = mode === "shared" ? "paperclip_runner" : f.adapterType; + await db.update(agents).set({ adapterType, + ...(adapterType === "paperclip_runner" ? { adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } } : {}), + runtimeConfig: { aiConnection: binding }, + }).where(eq(agents.id, f.agentId)); + const [before] = await db.select().from(agents).where(eq(agents.id, f.agentId)); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", userId: f.userId, companyIds: [f.companyId], source: "session" }; + next(); + }); + app.use("/api", agentRoutes(db)); + app.use(errorHandler); + const original = getServerAdapter(adapterType); + const probe = vi.fn(async () => { throw new Error("The runtime is unavailable; unrelated edits must still save"); }); + registerServerAdapter({ ...original, testEnvironment: probe }); + try { + for (const patch of [ + { title: "Updated title" }, + { adapterConfig: { reasoningEffort: "high" } }, + { defaultEnvironmentId: null }, + { runtimeConfig: { ...before.runtimeConfig, heartbeat: { enabled: false } } }, + ]) { + const response = await request(app).patch(`/api/agents/${f.agentId}`).send(patch); + expect(response.status, JSON.stringify(response.body)).toBe(200); + expect(response.body.runtimeConfig.aiConnection).toEqual(binding); + } + expect(probe).not.toHaveBeenCalled(); + const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); + expect(saved).toMatchObject({ title: "Updated title", adapterConfig: { reasoningEffort: "high" }, + runtimeConfig: { aiConnection: binding, heartbeat: { enabled: false } } }); + expect(await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.agentId), eq(activityLog.action, "agent.updated")))).toHaveLength(4); + } finally { unregisterServerAdapter(adapterType); } + }); + + it.each(["model", "engine", "environment", "harness", "connection"] as const)("probes a changed %s selection and preserves the saved configuration on failure", async (selection) => { + const f = await fixture("openai"); + await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "agents:configure" }); + const [environment] = await db.select().from(environments).where(eq(environments.driver, "local")); + const adapterType = selection === "harness" ? "paperclip_runner" : f.adapterType; + const original = getServerAdapter(adapterType); + const probe = vi.fn(async () => ({ adapterType, status: "fail" as const, testedAt: new Date().toISOString(), + checks: [{ code: "fixture_runtime_unavailable", level: "error" as const, message: "The selected runtime is unavailable" }], + })); + registerServerAdapter({ ...original, testEnvironment: probe }); + const patch = selection === "environment" ? { defaultEnvironmentId: environment.id } + : selection === "harness" ? { adapterType, adapterConfig: { provider: "codex", model: "gpt-5.6-sol" } } + : selection === "connection" ? { runtimeConfig: { aiConnection: { ...f.binding, mode: "delegated", ...f.account } } } + : { adapterConfig: { [selection]: selection === "model" ? "changed-model" : "cli" } }; + try { + const response = await request(f.app).patch(`/api/agents/${f.agentId}`).send(patch); + expect(response.status, JSON.stringify(response.body)).toBe(422); + expect(probe).toHaveBeenCalledOnce(); + const [saved] = await db.select().from(agents).where(eq(agents.id, f.agentId)); + expect(saved).toMatchObject({ adapterType: f.adapterType, adapterConfig: {}, defaultEnvironmentId: null, + runtimeConfig: { aiConnection: f.binding } }); + } finally { unregisterServerAdapter(adapterType); } + }); + it("does not install a restricted shared connection through pool membership", async () => { const f = await fixture("openai"); const otherOwner = `other-${f.companyId}`; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index e05270bc240..d81f90d21cb 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -50,6 +50,7 @@ import { executionProjectionForRun, executionProjectionsForRuns } from "../servi import { selectDashboardRunIds } from "../services/dashboard-run-selection.js"; import { Router, type NextFunction, type Request, type Response } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import type { Db } from "@paperclipai/db"; @@ -5599,7 +5600,7 @@ export function agentRoutes( const nextAiBinding = aiRuntimeConnectionBindingSchema.safeParse(requestedRuntimeConfig ? requestedRuntimeConfig.aiConnection : existing.runtimeConfig.aiConnection).data; if (nextAiBinding) { await assertCanConfigureAgentSetup(req, existing); - const changed = JSON.stringify(nextAiBinding) !== JSON.stringify(existing.runtimeConfig.aiConnection); + const changed = !isDeepStrictEqual(nextAiBinding, existing.runtimeConfig.aiConnection); if (changed && req.actor.type === "agent") { await connectionManagementService(db).authorize(req.actor, "connections:profiles", { type: "connection_management", companyId: existing.companyId, purpose: "ai", targetAgentId: existing.id, @@ -5608,10 +5609,17 @@ export function agentRoutes( }); } const aiConfig = (patchData.adapterConfig ?? existing.adapterConfig) as Record; + const nextEnvironmentId = Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId") + ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId; + const runtimeSelectionChanged = requestedAdapterType !== existing.adapterType + || nextEnvironmentId !== existing.defaultEnvironmentId + || (nextAiBinding.mode !== "router" && ["model", "provider", "acpxAgent", "engine"].some( + key => aiConfig[key] !== existing.adapterConfig[key], + )); 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"); const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot" && (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")); - if (runtimeSettings || changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) { + if (runtimeSettings || changed || runtimeSelectionChanged || copilotConfigurationChanged) { const configuringSetup = isAgentSetupDraft(existing); await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding, (patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null,